diff --git a/docs/ai/后端实现指南.md b/docs/ai/后端实现指南.md index c4bde09..e9f7e38 100644 --- a/docs/ai/后端实现指南.md +++ b/docs/ai/后端实现指南.md @@ -9,7 +9,8 @@ ### 1. 修改 credit_mp_customer 表结构 ```sql --- 为第5-7步添加字段(第1-4步字段已存在) +-- 为第5-7步添加字段(第1-4步基础字段已存在;如未包含审核时间/审核人字段,请先补齐 step1-4 的 approved_at/approved_by) +-- 参考:docs/sql/credit_mp_customer_step1_4_approval_columns.sql -- 第5步:合同签订 ALTER TABLE credit_mp_customer ADD COLUMN follow_step5_submitted TINYINT DEFAULT 0 COMMENT '是否已提交'; ALTER TABLE credit_mp_customer ADD COLUMN follow_step5_submitted_at VARCHAR(255) COMMENT '提交时间'; diff --git a/docs/app_config.sql b/docs/app_config.sql new file mode 100644 index 0000000..dfe7f81 --- /dev/null +++ b/docs/app_config.sql @@ -0,0 +1,19 @@ +-- 应用配置表 +CREATE TABLE app_config ( + config_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', + website_id INT NOT NULL COMMENT '应用ID', + config_key VARCHAR(100) NOT NULL COMMENT '配置键', + config_value TEXT NOT NULL COMMENT '配置值(JSON或字符串)', + config_type VARCHAR(50) NOT NULL DEFAULT 'general' COMMENT '配置类型:general/api/callback/wechat/payment/git等', + is_encrypted TINYINT DEFAULT 0 COMMENT '是否加密 0否 1是', + is_secret TINYINT DEFAULT 0 COMMENT '是否敏感信息 0否 1是', + description VARCHAR(500) DEFAULT '' COMMENT '配置说明', + sort_number INT DEFAULT 0 COMMENT '排序号', + tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户id', + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted TINYINT DEFAULT 0 COMMENT '是否删除 0否 1是', + UNIQUE KEY uk_website_key (website_id, config_key, deleted), + INDEX idx_website_type (website_id, config_type), + INDEX idx_tenant (tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用配置表'; diff --git a/docs/sql/2026-03-30_app_ticket_tables.sql b/docs/sql/2026-03-30_app_ticket_tables.sql new file mode 100644 index 0000000..6d621e4 --- /dev/null +++ b/docs/sql/2026-03-30_app_ticket_tables.sql @@ -0,0 +1,48 @@ +-- 工单主表 +CREATE TABLE IF NOT EXISTS `app_ticket` ( + `ticket_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '工单ID', + `ticket_no` VARCHAR(32) NOT NULL COMMENT '工单编号(TK-yyyyMMddHHmmss+4位随机)', + `title` VARCHAR(200) NOT NULL COMMENT '工单标题', + `content` TEXT NOT NULL COMMENT '工单内容描述', + `website_id` BIGINT DEFAULT NULL COMMENT '关联应用ID', + `website_name` VARCHAR(100) DEFAULT NULL COMMENT '应用名称(冗余)', + `category` VARCHAR(30) NOT NULL DEFAULT 'other' COMMENT '分类: bug/feature/consultation/complaint/other', + `priority` VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT '优先级: low/normal/high/urgent', + `status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/assigned/processing/resolved/closed/rejected', + `attachments` TEXT DEFAULT NULL COMMENT '附件JSON数组', + `submit_user_id` INT NOT NULL COMMENT '提交人用户ID', + `submit_user_name` VARCHAR(50) DEFAULT NULL COMMENT '提交人昵称(冗余)', + `submit_user_avatar` VARCHAR(500) DEFAULT NULL COMMENT '提交人头像(冗余)', + `assignee_id` INT DEFAULT NULL COMMENT '处理人用户ID', + `assignee_name` VARCHAR(50) DEFAULT NULL COMMENT '处理人昵称(冗余)', + `assignee_avatar` VARCHAR(500) DEFAULT NULL COMMENT '处理人头像(冗余)', + `reply_count` INT NOT NULL DEFAULT 0 COMMENT '回复数量', + `resolved_time` DATETIME DEFAULT NULL COMMENT '解决时间', + `closed_time` DATETIME DEFAULT NULL COMMENT '关闭时间', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除: 0否 1是', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`ticket_id`), + UNIQUE KEY `uk_ticket_no` (`ticket_no`), + KEY `idx_submit_user` (`submit_user_id`), + KEY `idx_assignee` (`assignee_id`), + KEY `idx_website_status` (`website_id`, `status`), + KEY `idx_status_create` (`status`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用工单'; + +-- 工单回复表 +CREATE TABLE IF NOT EXISTS `app_ticket_reply` ( + `reply_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '回复ID', + `ticket_id` BIGINT NOT NULL COMMENT '关联工单ID', + `content` TEXT NOT NULL COMMENT '回复内容', + `attachments` TEXT DEFAULT NULL COMMENT '附件JSON数组', + `user_id` INT NOT NULL COMMENT '回复人用户ID', + `user_name` VARCHAR(50) DEFAULT NULL COMMENT '回复人昵称(冗余)', + `user_avatar` VARCHAR(500) DEFAULT NULL COMMENT '回复人头像(冗余)', + `is_staff` TINYINT NOT NULL DEFAULT 0 COMMENT '是否技术人员/客服: 0否 1是', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除: 0否 1是', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`reply_id`), + KEY `idx_ticket_id` (`ticket_id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单回复'; diff --git a/docs/sql/credit_mp_customer_step1_4_approval_columns.sql b/docs/sql/credit_mp_customer_step1_4_approval_columns.sql new file mode 100644 index 0000000..9dbbe0f --- /dev/null +++ b/docs/sql/credit_mp_customer_step1_4_approval_columns.sql @@ -0,0 +1,24 @@ +-- 修复:审核接口(updateStepApproval)会写入 follow_step{1..4}_approved_at / follow_step{1..4}_approved_by +-- 若数据库缺少这些列,会触发:Unknown column 'follow_step1_approved_at' in 'field list' +-- +-- 建议先检查: +-- SHOW COLUMNS FROM credit_mp_customer LIKE 'follow_step%_approved%'; +-- +-- 然后按需执行下面的 ALTER TABLE(如果你的 MySQL 版本支持,也可以改为 ADD COLUMN IF NOT EXISTS)。 + +-- 第1步:案件受理 +ALTER TABLE credit_mp_customer ADD COLUMN follow_step1_approved_at VARCHAR(255) NULL COMMENT '第1步审核时间'; +ALTER TABLE credit_mp_customer ADD COLUMN follow_step1_approved_by BIGINT NULL COMMENT '第1步审核人ID'; + +-- 第2步:材料准备 +ALTER TABLE credit_mp_customer ADD COLUMN follow_step2_approved_at VARCHAR(255) NULL COMMENT '第2步审核时间'; +ALTER TABLE credit_mp_customer ADD COLUMN follow_step2_approved_by BIGINT NULL COMMENT '第2步审核人ID'; + +-- 第3步:案件办理 +ALTER TABLE credit_mp_customer ADD COLUMN follow_step3_approved_at VARCHAR(255) NULL COMMENT '第3步审核时间'; +ALTER TABLE credit_mp_customer ADD COLUMN follow_step3_approved_by BIGINT NULL COMMENT '第3步审核人ID'; + +-- 第4步:送达签收 +ALTER TABLE credit_mp_customer ADD COLUMN follow_step4_approved_at VARCHAR(255) NULL COMMENT '第4步审核时间'; +ALTER TABLE credit_mp_customer ADD COLUMN follow_step4_approved_by BIGINT NULL COMMENT '第4步审核人ID'; + diff --git a/src/main/java/com/gxwebsoft/app/controller/AppConfigController.java b/src/main/java/com/gxwebsoft/app/controller/AppConfigController.java new file mode 100644 index 0000000..39fab56 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/controller/AppConfigController.java @@ -0,0 +1,129 @@ +package com.gxwebsoft.app.controller; + +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.app.entity.AppConfig; +import com.gxwebsoft.app.param.AppConfigParam; +import com.gxwebsoft.app.service.AppConfigService; +import com.gxwebsoft.common.core.web.ApiResult; +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.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Map; + +/** + * 应用配置表 Controller + */ +@Slf4j +@Tag(name = "应用配置管理") +@RestController +@RequestMapping("/api/app/app-config") +public class AppConfigController extends BaseController { + + @Resource + private AppConfigService appConfigService; + + /** + * 分页查询应用配置 + */ + @Operation(summary = "分页查询应用配置") + @GetMapping("/page") + public ApiResult> page(AppConfigParam param) { + return success(new PageResult<>(appConfigService.page(param))); + } + + /** + * 获取应用配置列表 + */ + @Operation(summary = "获取应用配置列表") + @GetMapping() + public ApiResult> list(AppConfigParam param) { + return success(appConfigService.list(param)); + } + + /** + * 根据应用ID获取配置映射 + */ + @Operation(summary = "根据应用ID获取配置映射") + @GetMapping("/map/{websiteId}") + public ApiResult> getConfigsByWebsiteId(@PathVariable Integer websiteId) { + return success(appConfigService.getConfigsByWebsiteId(websiteId)); + } + + /** + * 获取单个配置值 + */ + @Operation(summary = "获取单个配置值") + @GetMapping("/value") + public ApiResult getConfigValue(@RequestParam Integer websiteId, @RequestParam String configKey) { + return success(appConfigService.getConfigValue(websiteId, configKey),null); + } + + /** + * 保存配置 + */ + @Operation(summary = "保存配置") + @PostMapping() + public ApiResult save(@RequestBody AppConfig config) { + appConfigService.saveConfig(config); + return success("保存成功"); + } + + /** + * 批量保存配置 + */ + @Operation(summary = "批量保存配置") + @PostMapping("/batch") + public ApiResult batchSave(@RequestBody BatchSaveRequest request) { + appConfigService.batchSaveConfig(request.getWebsiteId(), request.getConfigs()); + return success("保存成功"); + } + + /** + * 更新配置 + */ + @Operation(summary = "更新配置") + @PutMapping() + public ApiResult update(@RequestBody AppConfig config) { + appConfigService.updateConfig(config); + return success("更新成功"); + } + + /** + * 删除配置 + */ + @Operation(summary = "删除配置") + @DeleteMapping("/{configId}") + public ApiResult delete(@PathVariable Integer configId) { + appConfigService.deleteConfig(configId); + return success("删除成功"); + } + + /** + * 批量保存请求对象 + */ + public static class BatchSaveRequest { + private Integer websiteId; + private List configs; + + public Integer getWebsiteId() { + return websiteId; + } + + public void setWebsiteId(Integer websiteId) { + this.websiteId = websiteId; + } + + public List getConfigs() { + return configs; + } + + public void setConfigs(List configs) { + this.configs = configs; + } + } +} diff --git a/src/main/java/com/gxwebsoft/app/controller/AppCredentialController.java b/src/main/java/com/gxwebsoft/app/controller/AppCredentialController.java new file mode 100644 index 0000000..6b9ce15 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/controller/AppCredentialController.java @@ -0,0 +1,151 @@ +package com.gxwebsoft.app.controller; + +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.app.service.AppCredentialService; +import com.gxwebsoft.app.entity.AppCredential; +import com.gxwebsoft.app.param.AppCredentialParam; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.system.entity.User; +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.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 应用密钥凭证控制器 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Slf4j +@Tag(name = "应用密钥凭证管理") +@RestController +@RequestMapping("/api/app/app-credential") +public class AppCredentialController extends BaseController { + + @Resource + private AppCredentialService appCredentialService; + + @Operation(summary = "分页查询应用密钥凭证") + @GetMapping("/page") + public ApiResult> page(AppCredentialParam param) { + return success(appCredentialService.pageRel(param)); + } + + @Operation(summary = "查询全部应用密钥凭证") + @GetMapping() + public ApiResult> list(AppCredentialParam param) { + return success(appCredentialService.listRel(param)); + } + + @Operation(summary = "根据id查询应用密钥凭证") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + return success(appCredentialService.getByIdRel(id)); + } + + @PreAuthorize("hasAuthority('app:appCredential:save')") + @OperationLog + @Operation(summary = "创建应用密钥凭证(自动生成 AppID 和 AppSecret)") + @PostMapping() + public ApiResult save(@RequestBody AppCredential appCredential) { + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录"); + } + appCredential.setUserId(loginUser.getUserId()); + // 创建并生成密钥 + AppCredential result = appCredentialService.createCredential(appCredential); + return success("创建成功,请保存 AppSecret,该信息仅展示一次", result); + } + + @PreAuthorize("hasAuthority('app:appCredential:update')") + @OperationLog + @Operation(summary = "修改应用密钥凭证(名称/类型/备注等,不含密钥)") + @PutMapping() + public ApiResult update(@RequestBody AppCredential appCredential) { + // 防止通过此接口直接修改 appId/appSecret + appCredential.setAppId(null); + appCredential.setAppSecret(null); + if (appCredentialService.updateById(appCredential)) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('app:appCredential:update')") + @OperationLog + @Operation(summary = "重置 AppSecret(重新生成密钥)") + @PostMapping("/resetSecret/{id}") + public ApiResult resetSecret(@PathVariable("id") Long id) { + try { + AppCredential result = appCredentialService.resetSecret(id); + return success("重置成功,请保存新 AppSecret,该信息仅展示一次", result); + } catch (RuntimeException e) { + return fail(e.getMessage()); + } + } + + @PreAuthorize("hasAuthority('app:appCredential:update')") + @OperationLog + @Operation(summary = "禁用/启用凭证") + @PutMapping("/status/{id}/{status}") + public ApiResult updateStatus(@PathVariable("id") Long id, @PathVariable("status") Integer status) { + if (appCredentialService.updateStatus(id, status)) { + return success(status == 0 ? "已启用" : "已禁用"); + } + return fail("操作失败"); + } + + @PreAuthorize("hasAuthority('app:appCredential:remove')") + @OperationLog + @Operation(summary = "删除应用密钥凭证") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (appCredentialService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + @PreAuthorize("hasAuthority('app:appCredential:save')") + @OperationLog + @Operation(summary = "批量添加应用密钥凭证") + @PostMapping("/batch") + public ApiResult saveBatch(@RequestBody List list) { + if (appCredentialService.saveBatch(list)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @PreAuthorize("hasAuthority('app:appCredential:update')") + @OperationLog + @Operation(summary = "批量修改应用密钥凭证") + @PutMapping("/batch") + public ApiResult updateBatch(@RequestBody BatchParam batchParam) { + if (batchParam.update(appCredentialService, "id")) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('app:appCredential:remove')") + @OperationLog + @Operation(summary = "批量删除应用密钥凭证") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (appCredentialService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } + +} diff --git a/src/main/java/com/gxwebsoft/app/controller/AppEventController.java b/src/main/java/com/gxwebsoft/app/controller/AppEventController.java new file mode 100644 index 0000000..e963137 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/controller/AppEventController.java @@ -0,0 +1,133 @@ +package com.gxwebsoft.app.controller; + +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.app.service.AppEventService; +import com.gxwebsoft.app.entity.AppEvent; +import com.gxwebsoft.app.param.AppEventParam; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.system.entity.User; +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.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 应用操作动态控制器 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Slf4j +@Tag(name = "应用操作动态管理") +@RestController +@RequestMapping("/api/app/app-event") +public class AppEventController extends BaseController { + + @Resource + private AppEventService appEventService; + + @Operation(summary = "分页查询操作动态") + @GetMapping("/page") + public ApiResult> page(AppEventParam param) { + return success(appEventService.pageRel(param)); + } + + @Operation(summary = "查询全部操作动态") + @GetMapping() + public ApiResult> list(AppEventParam param) { + return success(appEventService.listRel(param)); + } + + @Operation(summary = "根据id查询操作动态") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + return success(appEventService.getByIdRel(id)); + } + + @Operation(summary = "获取应用最新一条动态(用于卡片展示)") + @GetMapping("/latest/{websiteId}") + public ApiResult getLatest(@PathVariable("websiteId") Long websiteId) { + return success(appEventService.getLatestEvent(websiteId)); + } + + @PreAuthorize("hasAuthority('app:appEvent:save')") + @OperationLog + @Operation(summary = "手动记录操作动态") + @PostMapping() + public ApiResult save(@RequestBody AppEvent appEvent) { + User loginUser = getLoginUser(); + if (loginUser != null) { + appEvent.setUserId(loginUser.getUserId()); + appEvent.setTenantId(loginUser.getTenantId()); + if (appEvent.getOperatorId() == null) { + appEvent.setOperatorId(loginUser.getUserId().longValue()); + } + if (appEvent.getOperator() == null) { + appEvent.setOperator(loginUser.getNickname()); + } + } + if (appEventService.save(appEvent)) { + return success("记录成功"); + } + return fail("记录失败"); + } + + @PreAuthorize("hasAuthority('app:appEvent:update')") + @OperationLog + @Operation(summary = "修改操作动态") + @PutMapping() + public ApiResult update(@RequestBody AppEvent appEvent) { + if (appEventService.updateById(appEvent)) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('app:appEvent:remove')") + @OperationLog + @Operation(summary = "删除操作动态") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (appEventService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + @PreAuthorize("hasAuthority('app:appEvent:remove')") + @OperationLog + @Operation(summary = "清空应用所有动态记录") + @DeleteMapping("/clear/{websiteId}") + public ApiResult clearByWebsiteId(@PathVariable("websiteId") Long websiteId) { + // 只清空当前租户下的数据 + AppEventParam param = new AppEventParam(); + param.setWebsiteId(websiteId); + List list = appEventService.listRel(param); + if (list.isEmpty()) { + return success("暂无动态记录"); + } + List ids = list.stream().map(AppEvent::getId).collect(java.util.stream.Collectors.toList()); + if (appEventService.removeByIds(ids)) { + return success("已清空 " + ids.size() + " 条动态记录"); + } + return fail("清空失败"); + } + + @PreAuthorize("hasAuthority('app:appEvent:remove')") + @OperationLog + @Operation(summary = "批量删除操作动态") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (appEventService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } + +} diff --git a/src/main/java/com/gxwebsoft/app/controller/AppResourceController.java b/src/main/java/com/gxwebsoft/app/controller/AppResourceController.java new file mode 100644 index 0000000..28fa031 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/controller/AppResourceController.java @@ -0,0 +1,145 @@ +package com.gxwebsoft.app.controller; + +import com.gxwebsoft.app.entity.AppResource; +import com.gxwebsoft.app.param.AppResourceParam; +import com.gxwebsoft.app.service.AppResourceService; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Map; + +/** + * 开发者资源管理控制器(服务器/数据库/云存储/域名/SSL) + * + * @author 科技小王子 + * @since 2026-03-31 + */ +@Slf4j +@Tag(name = "开发者资源管理") +@RestController +@RequestMapping("/api/app/developer-resource") +public class AppResourceController extends BaseController { + + @Resource + private AppResourceService appResourceService; + + // ─── 查询接口 ───────────────────────────────────────────────── + + @Operation(summary = "分页查询资源列表") + @GetMapping("/page") + public ApiResult> page(AppResourceParam param) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录", null); + // 普通开发者只能查自己的资源 + param.setUserId(loginUser.getUserId()); + param.setTenantId(loginUser.getTenantId()); + return success(appResourceService.pageRel(param)); + } + + @Operation(summary = "查询资源列表(不分页)") + @GetMapping + public ApiResult> list(AppResourceParam param) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录", null); + param.setUserId(loginUser.getUserId()); + param.setTenantId(loginUser.getTenantId()); + return success(appResourceService.listRel(param)); + } + + @Operation(summary = "获取资源详情") + @GetMapping("/{resourceId}") + public ApiResult get(@PathVariable Long resourceId) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录", null); + AppResource resource = appResourceService.getByIdRel(resourceId); + if (resource == null) return fail("资源不存在", null); + if (!resource.getUserId().equals(loginUser.getUserId())) return fail("无权访问此资源", null); + return success(resource); + } + + @Operation(summary = "统计各类型资源数量") + @GetMapping("/stats") + public ApiResult> stats() { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录", null); + return success(appResourceService.countByType(loginUser.getUserId(), loginUser.getTenantId())); + } + + // ─── 新增/修改接口 ──────────────────────────────────────────── + + @OperationLog + @Operation(summary = "新增资源") + @PostMapping + public ApiResult save(@RequestBody AppResource resource) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录", null); + if (resource.getResourceType() == null || resource.getResourceType().isEmpty()) { + return fail("资源类型不能为空", null); + } + if (resource.getName() == null || resource.getName().isEmpty()) { + return fail("资源名称不能为空", null); + } + resource.setTenantId(loginUser.getTenantId()); + try { + AppResource result = appResourceService.addResource(resource, loginUser.getUserId()); + return success("添加成功", result); + } catch (Exception e) { + return fail(e.getMessage(), null); + } + } + + @OperationLog + @Operation(summary = "修改资源") + @PutMapping + public ApiResult update(@RequestBody AppResource resource) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录", null); + try { + AppResource result = appResourceService.updateResource(resource); + return success("修改成功", result); + } catch (Exception e) { + return fail(e.getMessage(), null); + } + } + + // ─── 删除接口 ───────────────────────────────────────────────── + + @OperationLog + @Operation(summary = "删除资源(逻辑删除)") + @DeleteMapping("/{resourceId}") + public ApiResult remove(@PathVariable Long resourceId) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + try { + appResourceService.removeResource(resourceId, loginUser.getUserId()); + return success("删除成功"); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + @OperationLog + @Operation(summary = "批量删除资源") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + try { + for (Long id : ids) { + appResourceService.removeResource(id, loginUser.getUserId()); + } + return success("批量删除成功"); + } catch (Exception e) { + return fail(e.getMessage()); + } + } +} diff --git a/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java b/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java new file mode 100644 index 0000000..a247aee --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java @@ -0,0 +1,150 @@ +package com.gxwebsoft.app.controller; + +import com.gxwebsoft.app.entity.AppTicket; +import com.gxwebsoft.app.entity.AppTicketReply; +import com.gxwebsoft.app.param.AppTicketParam; +import com.gxwebsoft.app.service.AppTicketService; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Map; + +/** + * 应用工单控制器 + * + * @author 科技小王子 + * @since 2026-03-30 + */ +@Slf4j +@Tag(name = "应用工单管理") +@RestController +@RequestMapping("/api/app/ticket") +public class AppTicketController extends BaseController { + + @Resource + private AppTicketService appTicketService; + + // ─── 客户端接口 ──────────────────────────────────────────────── + + @Operation(summary = "查询我的工单(分页)") + @GetMapping("/my") + public ApiResult> myTickets(AppTicketParam param) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录",null); + return success(appTicketService.myPage(param, loginUser.getUserId())); + } + + @Operation(summary = "提交工单") + @PostMapping("/submit") + public ApiResult submit(@RequestBody AppTicket ticket) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录",null); + try { + AppTicket result = appTicketService.submit(ticket, loginUser.getUserId()); + return success("工单提交成功", result); + } catch (Exception e) { + return fail(e.getMessage(),null); + } + } + + @Operation(summary = "关闭工单(提交人)") + @PutMapping("/{ticketId}/close") + public ApiResult close(@PathVariable Long ticketId) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + try { + appTicketService.closeByUser(ticketId, loginUser.getUserId()); + return success("工单已关闭"); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + // ─── 技术端接口 ──────────────────────────────────────────────── + + @Operation(summary = "查询所有工单(技术人员)") + @GetMapping("/list") + public ApiResult> allTickets(AppTicketParam param) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录",null); + return success(appTicketService.allPage(param)); + } + + @Operation(summary = "获取工单详情") + @GetMapping("/{ticketId}") + public ApiResult detail(@PathVariable Long ticketId) { + return success(appTicketService.getById(ticketId)); + } + + @Operation(summary = "更新工单状态(技术人员)") + @PutMapping("/status") + public ApiResult updateStatus(@RequestBody Map body) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + Long ticketId = Long.valueOf(body.get("ticketId").toString()); + String status = body.get("status").toString(); + appTicketService.updateStatus(ticketId, status, loginUser.getUserId()); + return success("状态已更新"); + } + + @Operation(summary = "分配处理人(管理员)") + @PutMapping("/assign") + public ApiResult assign(@RequestBody Map body) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + Long ticketId = Long.valueOf(body.get("ticketId").toString()); + Integer assigneeId = Integer.valueOf(body.get("assigneeId").toString()); + appTicketService.assign(ticketId, assigneeId); + return success("分配成功"); + } + + // ─── 回复接口 ───────────────────────────────────────────────── + + @Operation(summary = "获取工单回复列表") + @GetMapping("/{ticketId}/replies") + public ApiResult> replies(@PathVariable Long ticketId) { + return success(appTicketService.getReplies(ticketId)); + } + + @Operation(summary = "提交工单回复") + @PostMapping("/reply") + public ApiResult reply(@RequestBody AppTicketReply reply) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录",null); + if (reply.getContent() == null || reply.getContent().trim().isEmpty()) { + return fail("回复内容不能为空",null); + } + try { + AppTicketReply result = appTicketService.addReply(reply, loginUser.getUserId()); + return success("回复成功", result); + } catch (Exception e) { + return fail(e.getMessage(),null); + } + } + + // ─── 统计 & 辅助 ───────────────────────────────────────────── + + @Operation(summary = "工单统计数据") + @GetMapping("/stats") + public ApiResult> stats( + @RequestParam(required = false) Long websiteId) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录",null); + // 技术端不限制用户维度;客户端通过路由区分 + return success(appTicketService.stats(websiteId, null)); + } + + @Operation(summary = "获取技术人员列表(用于分配)") + @GetMapping("/staff-list") + public ApiResult>> staffList() { + return success(appTicketService.getTechStaffList()); + } +} diff --git a/src/main/java/com/gxwebsoft/app/controller/AppUserController.java b/src/main/java/com/gxwebsoft/app/controller/AppUserController.java new file mode 100644 index 0000000..695369c --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/controller/AppUserController.java @@ -0,0 +1,167 @@ +package com.gxwebsoft.app.controller; + +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.app.service.AppUserService; +import com.gxwebsoft.app.entity.AppUser; +import com.gxwebsoft.app.param.AppUserParam; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.system.entity.User; +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.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 应用成员控制器 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Slf4j +@Tag(name = "应用成员管理") +@RestController +@RequestMapping("/api/app/app-user") +public class AppUserController extends BaseController { + + @Resource + private AppUserService appUserService; + + @Operation(summary = "分页查询应用成员") + @GetMapping("/page") + public ApiResult> page(AppUserParam param) { + return success(appUserService.pageRel(param)); + } + + @Operation(summary = "查询全部应用成员") + @GetMapping() + public ApiResult> list(AppUserParam param) { + return success(appUserService.listRel(param)); + } + + @Operation(summary = "根据id查询应用成员") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + return success(appUserService.getByIdRel(id)); + } + + @PreAuthorize("hasAuthority('app:appUser:save')") + @OperationLog + @Operation(summary = "添加应用成员(手动添加)") + @PostMapping() + public ApiResult save(@RequestBody AppUser appUser) { + User loginUser = getLoginUser(); + if (loginUser != null) { + appUser.setUserId(loginUser.getUserId()); + appUser.setTenantId(loginUser.getTenantId()); + } + if (appUserService.save(appUser)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @Operation(summary = "邀请用户成为应用成员(支持用户ID或手机号)") + @PostMapping("/invite") + public ApiResult invite(@RequestBody AppUser appUser) { + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录"); + } + // 支持手机号邀请:若 userId 为空但传了 phone,则先按手机号查出用户 + if (appUser.getUserId() == null && appUser.getPhone() != null && !appUser.getPhone().isEmpty()) { + User targetUser = appUserService.findUserByPhone(appUser.getPhone()); + if (targetUser == null) { + return fail("手机号未注册,请确认后再试"); + } + appUser.setUserId(targetUser.getUserId()); + } + if (appUser.getUserId() == null) { + return fail("请输入用户ID或手机号"); + } + try { + AppUser result = appUserService.inviteUser( + appUser.getWebsiteId(), + appUser.getUserId(), + appUser.getRole(), + loginUser.getUserId() + ); + return success("邀请成功", result); + } catch (RuntimeException e) { + return fail(e.getMessage()); + } + } + + @PreAuthorize("hasAuthority('app:appUser:update')") + @OperationLog + @Operation(summary = "修改应用成员信息") + @PutMapping() + public ApiResult update(@RequestBody AppUser appUser) { + if (appUserService.updateById(appUser)) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('app:appUser:update')") + @OperationLog + @Operation(summary = "修改成员角色") + @PutMapping("/role/{id}/{role}") + public ApiResult updateRole(@PathVariable("id") Long id, @PathVariable("role") String role) { + if (appUserService.updateRole(id, role)) { + return success("角色修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('app:appUser:remove')") + @OperationLog + @Operation(summary = "移除应用成员") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (appUserService.removeById(id)) { + return success("已移除"); + } + return fail("移除失败"); + } + + @PreAuthorize("hasAuthority('app:appUser:save')") + @OperationLog + @Operation(summary = "批量添加应用成员") + @PostMapping("/batch") + public ApiResult saveBatch(@RequestBody List list) { + if (appUserService.saveBatch(list)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @PreAuthorize("hasAuthority('app:appUser:update')") + @OperationLog + @Operation(summary = "批量修改应用成员") + @PutMapping("/batch") + public ApiResult updateBatch(@RequestBody BatchParam batchParam) { + if (batchParam.update(appUserService, "id")) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('app:appUser:remove')") + @OperationLog + @Operation(summary = "批量移除应用成员") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (appUserService.removeByIds(ids)) { + return success("移除成功"); + } + return fail("移除失败"); + } + +} diff --git a/src/main/java/com/gxwebsoft/app/controller/AppVersionController.java b/src/main/java/com/gxwebsoft/app/controller/AppVersionController.java new file mode 100644 index 0000000..50633fa --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/controller/AppVersionController.java @@ -0,0 +1,173 @@ +package com.gxwebsoft.app.controller; + +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.app.service.AppVersionService; +import com.gxwebsoft.app.entity.AppVersion; +import com.gxwebsoft.app.param.AppVersionParam; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.system.entity.User; +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.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 应用版本发布记录控制器 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Slf4j +@Tag(name = "应用版本发布管理") +@RestController +@RequestMapping("/api/app/app-version") +public class AppVersionController extends BaseController { + + @Resource + private AppVersionService appVersionService; + + @Operation(summary = "分页查询版本记录") + @GetMapping("/page") + public ApiResult> page(AppVersionParam param) { + return success(appVersionService.pageRel(param)); + } + + @Operation(summary = "查询全部版本记录") + @GetMapping() + public ApiResult> list(AppVersionParam param) { + return success(appVersionService.listRel(param)); + } + + @Operation(summary = "根据id查询版本") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + return success(appVersionService.getByIdRel(id)); + } + + @Operation(summary = "获取应用当前版本") + @GetMapping("/current/{websiteId}") + public ApiResult getCurrentVersion(@PathVariable("websiteId") Long websiteId) { + return success(appVersionService.getCurrentVersion(websiteId)); + } + + @PreAuthorize("hasAuthority('app:appVersion:save')") + @OperationLog + @Operation(summary = "新增版本(构建中状态)") + @PostMapping() + public ApiResult save(@RequestBody AppVersion appVersion) { + User loginUser = getLoginUser(); + if (loginUser != null) { + appVersion.setUserId(loginUser.getUserId()); + appVersion.setTenantId(loginUser.getTenantId()); + } + // 默认为构建中状态 + if (appVersion.getStatus() == null) { + appVersion.setStatus(0); + } + if (appVersion.getEnv() == null) { + appVersion.setEnv("production"); + } + appVersion.setIsCurrent(false); + if (appVersionService.save(appVersion)) { + return success("创建成功"); + } + return fail("创建失败"); + } + + @PreAuthorize("hasAuthority('app:appVersion:update')") + @OperationLog + @Operation(summary = "修改版本信息") + @PutMapping() + public ApiResult update(@RequestBody AppVersion appVersion) { + if (appVersionService.updateById(appVersion)) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('app:appVersion:update')") + @OperationLog + @Operation(summary = "发布版本(将此版本设为当前运行版本)") + @PostMapping("/publish/{id}") + public ApiResult publish(@PathVariable("id") Long id) { + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录"); + } + try { + appVersionService.publish(id, loginUser.getUserId()); + return success("发布成功"); + } catch (RuntimeException e) { + return fail(e.getMessage()); + } + } + + @PreAuthorize("hasAuthority('app:appVersion:update')") + @OperationLog + @Operation(summary = "回滚到指定版本") + @PostMapping("/rollback/{id}") + public ApiResult rollback(@PathVariable("id") Long id) { + User loginUser = getLoginUser(); + if (loginUser == null) { + return fail("请先登录"); + } + try { + appVersionService.rollback(id, loginUser.getUserId()); + return success("回滚成功"); + } catch (RuntimeException e) { + return fail(e.getMessage()); + } + } + + @PreAuthorize("hasAuthority('app:appVersion:remove')") + @OperationLog + @Operation(summary = "删除版本记录") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (appVersionService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + @PreAuthorize("hasAuthority('app:appVersion:save')") + @OperationLog + @Operation(summary = "批量添加版本") + @PostMapping("/batch") + public ApiResult saveBatch(@RequestBody List list) { + if (appVersionService.saveBatch(list)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @PreAuthorize("hasAuthority('app:appVersion:update')") + @OperationLog + @Operation(summary = "批量修改版本") + @PutMapping("/batch") + public ApiResult updateBatch(@RequestBody BatchParam batchParam) { + if (batchParam.update(appVersionService, "id")) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @PreAuthorize("hasAuthority('app:appVersion:remove')") + @OperationLog + @Operation(summary = "批量删除版本记录") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (appVersionService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } + +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppConfig.java b/src/main/java/com/gxwebsoft/app/entity/AppConfig.java new file mode 100644 index 0000000..d8c77df --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppConfig.java @@ -0,0 +1,87 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; + +/** + * 应用配置表 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("app_config") +public class AppConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 配置ID + */ + @TableId(value = "config_id", type = IdType.AUTO) + private Integer configId; + + /** + * 应用ID + */ + private Integer websiteId; + + /** + * 配置键 + */ + private String configKey; + + /** + * 配置值(JSON或字符串) + */ + private String configValue; + + /** + * 配置类型:general/api/callback/wechat/payment/git等 + */ + private String configType; + + /** + * 是否加密 0否 1是 + */ + private Integer isEncrypted; + + /** + * 是否敏感信息 0否 1是 + */ + private Integer isSecret; + + /** + * 配置说明 + */ + private String description; + + /** + * 排序号 + */ + private Integer sortNumber; + + /** + * 租户id + */ + private Long tenantId; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Long createdTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updatedTime; + + /** + * 是否删除 0否 1是 + */ + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppCredential.java b/src/main/java/com/gxwebsoft/app/entity/AppCredential.java new file mode 100644 index 0000000..2384db5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppCredential.java @@ -0,0 +1,80 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import java.time.LocalDateTime; +import com.baomidou.mybatisplus.annotation.TableLogic; +import java.io.Serializable; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 应用密钥凭证 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:43 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "AppCredential对象", description = "应用密钥凭证") +public class AppCredential implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @Schema(description = "关联应用ID") + private Long websiteId; + + @Schema(description = "凭证名称,如生产环境密钥") + private String name; + + @Schema(description = "App ID(公开)") + private String appId; + + @Schema(description = "App Secret(加密存储)") + private String appSecret; + + @Schema(description = "凭证类型: server/client/webhook") + private String type; + + @Schema(description = "权限范围,空格分隔") + private String scopes; + + @Schema(description = "到期时间,NULL=永不过期") + private LocalDateTime expireTime; + + @Schema(description = "最后使用时间") + private LocalDateTime lastUsedAt; + + private String remark; + + @Schema(description = "排序(数字越小越靠前)") + private Integer sortNumber; + + @Schema(description = "状态, 0正常, 1冻结") + private Integer status; + + @Schema(description = "是否删除, 0否, 1是") + @TableLogic + private Integer deleted; + + @Schema(description = "用户ID") + private Integer userId; + + @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; + +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppEvent.java b/src/main/java/com/gxwebsoft/app/entity/AppEvent.java new file mode 100644 index 0000000..b3fb88c --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppEvent.java @@ -0,0 +1,76 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import java.time.LocalDateTime; +import java.io.Serializable; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 应用操作动态 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "AppEvent对象", description = "应用操作动态") +public class AppEvent implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @Schema(description = "关联应用ID") + private Long websiteId; + + @Schema(description = "事件类型: created/published/updated/domain_bound/member_added/status_changed") + private String eventType; + + @Schema(description = "事件标题,如已发布") + private String title; + + @Schema(description = "详细描述") + private String content; + + @Schema(description = "操作人用户ID") + private Long operatorId; + + @Schema(description = "操作人名称(冗余)") + private String operator; + + @Schema(description = "关联ID,如版本ID") + private Long refId; + + @Schema(description = "关联类型") + private String refType; + + @Schema(description = "扩展数据") + private String extra; + + @Schema(description = "排序(数字越小越靠前)") + private Integer sortNumber; + + @Schema(description = "状态, 0正常, 1冻结") + private Integer status; + + @Schema(description = "用户ID") + private Integer userId; + + @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; + +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppResource.java b/src/main/java/com/gxwebsoft/app/entity/AppResource.java new file mode 100644 index 0000000..2027876 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppResource.java @@ -0,0 +1,121 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +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.LocalDate; +import java.time.LocalDateTime; + +/** + * 开发者资源(服务器/数据库/云存储/域名/SSL证书) + * + * @author 科技小王子 + * @since 2026-03-31 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("app_resource") +@Schema(name = "AppResource对象", description = "开发者资源") +public class AppResource implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "资源ID") + @TableId(value = "resource_id", type = IdType.AUTO) + private Long resourceId; + + @Schema(description = "资源类型: server/database/storage/domain/ssl") + private String resourceType; + + @Schema(description = "资源名称") + private String name; + + @Schema(description = "服务商: tencent/aliyun/huawei/other") + private String provider; + + @Schema(description = "关联应用ID(可选)") + private Long websiteId; + + @Schema(description = "关联应用名称(冗余)") + private String websiteName; + + // ─── 服务器字段 ─────────────────────────────────────── + @Schema(description = "IP地址(服务器用)") + private String ip; + + // ─── 数据库字段 ─────────────────────────────────────── + @Schema(description = "数据库类型: MySQL/PostgreSQL/Redis/MongoDB(数据库用)") + private String dbType; + + @Schema(description = "连接主机地址(数据库用)") + private String host; + + @Schema(description = "连接端口(数据库用)") + private Integer port; + + // ─── 云存储字段 ─────────────────────────────────────── + @Schema(description = "地区/Region(云存储用)") + private String region; + + @Schema(description = "访问权限: public-read/private(云存储用)") + private String acl; + + @Schema(description = "已用空间(字节,云存储用)") + private Long usedBytes; + + // ─── 域名字段 ───────────────────────────────────────── + @Schema(description = "域名(域名用)") + private String domain; + + @Schema(description = "注册商(域名用)") + private String registrar; + + @Schema(description = "是否已备案(域名用)") + private Boolean icp; + + @Schema(description = "ICP备案号(域名用)") + private String icpNo; + + @Schema(description = "是否已绑定SSL(域名用,冗余)") + private Boolean sslBound; + + // ─── SSL证书字段 ────────────────────────────────────── + @Schema(description = "证书类型: DV/OV/EV(SSL用)") + private String certType; + + @Schema(description = "颁发机构(SSL用)") + private String issuer; + + // ─── 通用字段 ───────────────────────────────────────── + @Schema(description = "状态: running/stopped/expired/pending") + private String status; + + @Schema(description = "到期时间") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate expireAt; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "所属用户ID") + private Integer userId; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "是否删除: 0否 1是") + private Integer deleted; + + @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; +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppTicket.java b/src/main/java/com/gxwebsoft/app/entity/AppTicket.java new file mode 100644 index 0000000..a4aa64d --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppTicket.java @@ -0,0 +1,103 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.gxwebsoft.common.core.config.JsonArrayToStringDeserializer; +import com.gxwebsoft.common.core.config.JsonStringToArraySerializer; +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) +@TableName("app_ticket") +@Schema(name = "AppTicket对象", description = "应用工单") +public class AppTicket implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "工单ID") + @TableId(value = "ticket_id", type = IdType.AUTO) + private Long ticketId; + + @Schema(description = "工单编号(TK-yyyyMMddHHmmss+4位随机)") + private String ticketNo; + + @Schema(description = "工单标题") + private String title; + + @Schema(description = "工单内容描述") + private String content; + + @Schema(description = "关联应用ID") + private Long websiteId; + + @Schema(description = "应用名称(冗余)") + private String websiteName; + + @Schema(description = "工单分类: bug/feature/consultation/complaint/other") + private String category; + + @Schema(description = "优先级: low/normal/high/urgent") + private String priority; + + @Schema(description = "状态: pending/assigned/processing/resolved/closed/rejected") + private String status; + + @Schema(description = "附件JSON数组") + @JsonDeserialize(using = JsonArrayToStringDeserializer.class) + @JsonSerialize(using = JsonStringToArraySerializer.class) + private String attachments; + + @Schema(description = "提交人用户ID") + private Integer submitUserId; + + @Schema(description = "提交人昵称(冗余)") + private String submitUserName; + + @Schema(description = "提交人头像(冗余)") + private String submitUserAvatar; + + @Schema(description = "分配的处理人用户ID") + private Integer assigneeId; + + @Schema(description = "处理人昵称(冗余)") + private String assigneeName; + + @Schema(description = "处理人头像(冗余)") + private String assigneeAvatar; + + @Schema(description = "回复数量") + private Integer replyCount; + + @Schema(description = "解决时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime resolvedTime; + + @Schema(description = "关闭时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime closedTime; + + @Schema(description = "是否删除: 0否 1是") + private Integer deleted; + + @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; +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppTicketReply.java b/src/main/java/com/gxwebsoft/app/entity/AppTicketReply.java new file mode 100644 index 0000000..2633d94 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppTicketReply.java @@ -0,0 +1,64 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.gxwebsoft.common.core.config.JsonArrayToStringDeserializer; +import com.gxwebsoft.common.core.config.JsonStringToArraySerializer; +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) +@TableName("app_ticket_reply") +@Schema(name = "AppTicketReply对象", description = "工单回复") +public class AppTicketReply implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "回复ID") + @TableId(value = "reply_id", type = IdType.AUTO) + private Long replyId; + + @Schema(description = "关联工单ID") + private Long ticketId; + + @Schema(description = "回复内容") + private String content; + + @Schema(description = "附件JSON数组") + @JsonDeserialize(using = JsonArrayToStringDeserializer.class) + @JsonSerialize(using = JsonStringToArraySerializer.class) + private String attachments; + + @Schema(description = "回复人用户ID") + private Integer userId; + + @Schema(description = "回复人昵称(冗余)") + private String userName; + + @Schema(description = "回复人头像(冗余)") + private String userAvatar; + + @Schema(description = "是否是技术人员/客服: 0否 1是") + private Integer isStaff; + + @Schema(description = "是否删除: 0否 1是") + private Integer deleted; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppUser.java b/src/main/java/com/gxwebsoft/app/entity/AppUser.java new file mode 100644 index 0000000..09ade12 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppUser.java @@ -0,0 +1,73 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import java.time.LocalDateTime; +import java.io.Serializable; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 应用成员 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "AppUser对象", description = "应用成员") +public class AppUser implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @Schema(description = "关联应用ID") + private Long websiteId; + + @Schema(description = "用户ID") + private Integer userId; + + @Schema(description = "用户名(冗余)") + private String username; + + @Schema(description = "昵称(冗余)") + private String nickname; + + @Schema(description = "头像(冗余)") + private String avatar; + + @Schema(description = "手机号(冗余,脱敏存储)") + private String phone; + + @Schema(description = "角色: owner/admin/developer/viewer") + private String role; + + @Schema(description = "邀请人用户ID") + private Long inviteBy; + + @Schema(description = "加入时间") + private LocalDateTime inviteTime; + + @Schema(description = "排序(数字越小越靠前)") + private Integer sortNumber; + + @Schema(description = "状态, 0正常, 1冻结") + private Integer status; + + @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; + +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppVersion.java b/src/main/java/com/gxwebsoft/app/entity/AppVersion.java new file mode 100644 index 0000000..6442e8b --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppVersion.java @@ -0,0 +1,85 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import java.time.LocalDateTime; +import java.io.Serializable; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 应用版本发布记录 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "AppVersion对象", description = "应用版本发布记录") +public class AppVersion implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @Schema(description = "关联应用ID") + private Long websiteId; + + @Schema(description = "版本号,如 1.0.0") + private String versionNo; + + @Schema(description = "版本名称") + private String versionName; + + @Schema(description = "版本更新说明") + private String changelog; + + @Schema(description = "安装包地址") + private String packageUrl; + + @Schema(description = "包大小(字节)") + private Long packageSize; + + @Schema(description = "包MD5/SHA256") + private String packageHash; + + @Schema(description = "环境: development/staging/production") + private String env; + + @Schema(description = "状态 0=构建中 1=已发布 2=已回滚 3=构建失败") + private Integer status; + + @Schema(description = "是否为当前版本") + private Boolean isCurrent; + + @Schema(description = "发布人用户ID") + private Long publishBy; + + @Schema(description = "发布时间") + private LocalDateTime publishTime; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "排序(数字越小越靠前)") + private Integer sortNumber; + + @Schema(description = "用户ID") + private Integer userId; + + @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; + +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppConfigMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppConfigMapper.java new file mode 100644 index 0000000..1b428b4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppConfigMapper.java @@ -0,0 +1,43 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.app.entity.AppConfig; +import com.gxwebsoft.app.param.AppConfigParam; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 应用配置表 Mapper + * + * @author 科技小王子 + */ +@Mapper +public interface AppConfigMapper extends BaseMapper { + + /** + * 批量获取应用配置 + */ + List> selectConfigsByWebsiteId(Integer websiteId); + + /** + * 分页查询 + * + * @param page 分页对象 + * @param param 查询参数 + * @return List + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") AppConfigParam param); + + /** + * 查询全部 + * + * @param param 查询参数 + * @return List + */ + List selectListRel(@Param("param") AppConfigParam param); +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppCredentialMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppCredentialMapper.java new file mode 100644 index 0000000..4ab3599 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppCredentialMapper.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.app.entity.AppCredential; +import com.gxwebsoft.app.param.AppCredentialParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 应用密钥凭证Mapper + * + * @author 科技小王子 + * @since 2026-03-28 21:29:43 + */ +public interface AppCredentialMapper extends BaseMapper { + + /** + * 分页查询 + * + * @param page 分页对象 + * @param param 查询参数 + * @return List + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") AppCredentialParam param); + + /** + * 查询全部 + * + * @param param 查询参数 + * @return List + */ + List selectListRel(@Param("param") AppCredentialParam param); + +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppEventMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppEventMapper.java new file mode 100644 index 0000000..c1fe3ca --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppEventMapper.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.app.entity.AppEvent; +import com.gxwebsoft.app.param.AppEventParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 应用操作动态Mapper + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +public interface AppEventMapper extends BaseMapper { + + /** + * 分页查询 + * + * @param page 分页对象 + * @param param 查询参数 + * @return List + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") AppEventParam param); + + /** + * 查询全部 + * + * @param param 查询参数 + * @return List + */ + List selectListRel(@Param("param") AppEventParam param); + +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppResourceMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppResourceMapper.java new file mode 100644 index 0000000..94847b6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppResourceMapper.java @@ -0,0 +1,35 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.app.entity.AppResource; +import com.gxwebsoft.app.param.AppResourceParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 开发者资源 Mapper + * + * @author 科技小王子 + * @since 2026-03-31 + */ +public interface AppResourceMapper extends BaseMapper { + + /** + * 分页查询(关联应用名称) + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") AppResourceParam param); + + /** + * 查询全部列表(关联应用名称) + */ + List selectListRel(@Param("param") AppResourceParam param); + + /** + * 统计各类型资源数量,返回 [{resourceType, cnt}] + */ + List> countByType(@Param("userId") Integer userId, + @Param("tenantId") Integer tenantId); +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppTicketMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppTicketMapper.java new file mode 100644 index 0000000..63e30e8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppTicketMapper.java @@ -0,0 +1,11 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.app.entity.AppTicket; +import org.apache.ibatis.annotations.Param; + +/** + * 应用工单 Mapper + */ +public interface AppTicketMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppTicketReplyMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppTicketReplyMapper.java new file mode 100644 index 0000000..3fc0a16 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppTicketReplyMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.app.entity.AppTicketReply; + +/** + * 工单回复 Mapper + */ +public interface AppTicketReplyMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppUserMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppUserMapper.java new file mode 100644 index 0000000..a2cca59 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppUserMapper.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.app.entity.AppUser; +import com.gxwebsoft.app.param.AppUserParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 应用成员Mapper + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +public interface AppUserMapper extends BaseMapper { + + /** + * 分页查询 + * + * @param page 分页对象 + * @param param 查询参数 + * @return List + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") AppUserParam param); + + /** + * 查询全部 + * + * @param param 查询参数 + * @return List + */ + List selectListRel(@Param("param") AppUserParam param); + +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppVersionMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppVersionMapper.java new file mode 100644 index 0000000..187b1a6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppVersionMapper.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.app.entity.AppVersion; +import com.gxwebsoft.app.param.AppVersionParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 应用版本发布记录Mapper + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +public interface AppVersionMapper extends BaseMapper { + + /** + * 分页查询 + * + * @param page 分页对象 + * @param param 查询参数 + * @return List + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") AppVersionParam param); + + /** + * 查询全部 + * + * @param param 查询参数 + * @return List + */ + List selectListRel(@Param("param") AppVersionParam param); + +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/xml/AppConfigMapper.xml b/src/main/java/com/gxwebsoft/app/mapper/xml/AppConfigMapper.xml new file mode 100644 index 0000000..c4679fa --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/xml/AppConfigMapper.xml @@ -0,0 +1,66 @@ + + + + + + + SELECT a.* + FROM app_config a + + a.deleted = 0 + + AND a.config_id = #{param.configId} + + + AND a.website_id = #{param.websiteId} + + + AND a.config_key LIKE CONCAT('%', #{param.configKey}, '%') + + + AND a.config_type = #{param.configType} + + + AND a.is_secret = #{param.isSecret} + + + AND a.tenant_id = #{param.tenantId} + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND a.config_key LIKE CONCAT('%', #{param.keywords}, '%') + + + + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/app/mapper/xml/AppCredentialMapper.xml b/src/main/java/com/gxwebsoft/app/mapper/xml/AppCredentialMapper.xml new file mode 100644 index 0000000..5c709ff --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/xml/AppCredentialMapper.xml @@ -0,0 +1,71 @@ + + + + + + + SELECT a.*, w.website_name, w.website_code, w.website_icon + FROM app_credential a + LEFT JOIN cms_website w ON a.website_id = w.website_id AND w.deleted = 0 + + + AND a.id = #{param.id} + + + AND a.website_id = #{param.websiteId} + + + AND a.name LIKE CONCAT('%', #{param.name}, '%') + + + AND a.app_id = #{param.appId} + + + AND a.type = #{param.type} + + + AND a.scopes LIKE CONCAT('%', #{param.scopes}, '%') + + + AND a.remark LIKE CONCAT('%', #{param.remark}, '%') + + + AND a.sort_number = #{param.sortNumber} + + + AND a.status = #{param.status} + + + AND a.deleted = #{param.deleted} + + + AND a.deleted = 0 + + + AND a.user_id = #{param.userId} + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND (a.name LIKE CONCAT('%', #{param.keywords}, '%') + OR a.app_id LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/app/mapper/xml/AppEventMapper.xml b/src/main/java/com/gxwebsoft/app/mapper/xml/AppEventMapper.xml new file mode 100644 index 0000000..8317ac1 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/xml/AppEventMapper.xml @@ -0,0 +1,66 @@ + + + + + + + SELECT a.*, w.website_name, w.website_code, w.website_icon + FROM app_event a + LEFT JOIN cms_website w ON a.website_id = w.website_id AND w.deleted = 0 + + + AND a.id = #{param.id} + + + AND a.website_id = #{param.websiteId} + + + AND a.event_type = #{param.eventType} + + + AND a.title LIKE CONCAT('%', #{param.title}, '%') + + + AND a.operator_id = #{param.operatorId} + + + AND a.ref_id = #{param.refId} + + + AND a.ref_type = #{param.refType} + + + AND a.status = #{param.status} + + + AND a.user_id = #{param.userId} + + + AND a.tenant_id = #{param.tenantId} + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND (a.title LIKE CONCAT('%', #{param.keywords}, '%') + OR a.content LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + ORDER BY a.create_time DESC + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/app/mapper/xml/AppResourceMapper.xml b/src/main/java/com/gxwebsoft/app/mapper/xml/AppResourceMapper.xml new file mode 100644 index 0000000..292b82c --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/xml/AppResourceMapper.xml @@ -0,0 +1,75 @@ + + + + + + + SELECT a.*, w.website_name + FROM app_resource a + LEFT JOIN cms_website w ON a.website_id = w.website_id AND w.deleted = 0 + + a.deleted = 0 + + AND a.resource_id = #{param.resourceId} + + + AND a.resource_type = #{param.resourceType} + + + AND a.website_id = #{param.websiteId} + + + AND a.provider = #{param.provider} + + + AND a.status = #{param.status} + + + AND a.user_id = #{param.userId} + + + AND a.tenant_id = #{param.tenantId} + + + AND ( + a.name LIKE CONCAT('%', #{param.keywords}, '%') + OR a.ip LIKE CONCAT('%', #{param.keywords}, '%') + OR a.domain LIKE CONCAT('%', #{param.keywords}, '%') + OR a.host LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + ORDER BY a.create_time DESC + + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/app/mapper/xml/AppUserMapper.xml b/src/main/java/com/gxwebsoft/app/mapper/xml/AppUserMapper.xml new file mode 100644 index 0000000..eab0732 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/xml/AppUserMapper.xml @@ -0,0 +1,62 @@ + + + + + + + SELECT a.*, w.website_name, w.website_code, w.website_icon + FROM app_user a + LEFT JOIN cms_website w ON a.website_id = w.website_id AND w.deleted = 0 + + + AND a.id = #{param.id} + + + AND a.website_id = #{param.websiteId} + + + AND a.user_id = #{param.userId} + + + AND (a.username LIKE CONCAT('%', #{param.username}, '%') + OR a.nickname LIKE CONCAT('%', #{param.username}, '%')) + + + AND a.role = #{param.role} + + + AND a.invite_by = #{param.inviteBy} + + + AND a.sort_number = #{param.sortNumber} + + + AND a.status = #{param.status} + + + AND a.tenant_id = #{param.tenantId} + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND (a.username LIKE CONCAT('%', #{param.keywords}, '%') + OR a.nickname LIKE CONCAT('%', #{param.keywords}, '%')) + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/app/mapper/xml/AppVersionMapper.xml b/src/main/java/com/gxwebsoft/app/mapper/xml/AppVersionMapper.xml new file mode 100644 index 0000000..daf1699 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/xml/AppVersionMapper.xml @@ -0,0 +1,66 @@ + + + + + + + SELECT a.*, w.website_name, w.website_code, w.website_icon + FROM app_version a + LEFT JOIN cms_website w ON a.website_id = w.website_id AND w.deleted = 0 + + + AND a.id = #{param.id} + + + AND a.website_id = #{param.websiteId} + + + AND a.version_no = #{param.versionNo} + + + AND a.version_name LIKE CONCAT('%', #{param.versionName}, '%') + + + AND a.env = #{param.env} + + + AND a.status = #{param.status} + + + AND a.is_current = #{param.isCurrent} + + + AND a.publish_by = #{param.publishBy} + + + AND a.user_id = #{param.userId} + + + AND a.tenant_id = #{param.tenantId} + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND (a.version_no LIKE CONCAT('%', #{param.keywords}, '%') + OR a.version_name LIKE CONCAT('%', #{param.keywords}, '%') + OR a.changelog LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/app/param/AppConfigParam.java b/src/main/java/com/gxwebsoft/app/param/AppConfigParam.java new file mode 100644 index 0000000..48dcfc7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/param/AppConfigParam.java @@ -0,0 +1,38 @@ +package com.gxwebsoft.app.param; + +import com.gxwebsoft.common.core.web.BaseParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 应用配置表查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class AppConfigParam extends BaseParam { + + /** + * 配置ID + */ + private Integer configId; + + /** + * 应用ID + */ + private Integer websiteId; + + /** + * 配置键 + */ + private String configKey; + + /** + * 配置类型 + */ + private String configType; + + /** + * 是否敏感信息 + */ + private Integer isSecret; +} diff --git a/src/main/java/com/gxwebsoft/app/param/AppCredentialParam.java b/src/main/java/com/gxwebsoft/app/param/AppCredentialParam.java new file mode 100644 index 0000000..f40632c --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/param/AppCredentialParam.java @@ -0,0 +1,65 @@ +package com.gxwebsoft.app.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-28 21:29:43 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "AppCredentialParam对象", description = "应用密钥凭证查询参数") +public class AppCredentialParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @QueryField(type = QueryType.EQ) + private Long id; + + @Schema(description = "关联应用ID") + @QueryField(type = QueryType.EQ) + private Long websiteId; + + @Schema(description = "凭证名称,如生产环境密钥") + private String name; + + @Schema(description = "App ID(公开)") + @QueryField(type = QueryType.EQ) + private String appId; + + @Schema(description = "凭证类型: server/client/webhook") + @QueryField(type = QueryType.EQ) + private String type; + + @Schema(description = "权限范围,空格分隔") + private String scopes; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "排序(数字越小越靠前)") + @QueryField(type = QueryType.EQ) + private Integer sortNumber; + + @Schema(description = "状态, 0正常, 1冻结") + @QueryField(type = QueryType.EQ) + private Integer status; + + @Schema(description = "是否删除, 0否, 1是") + @QueryField(type = QueryType.EQ) + private Integer deleted; + + @Schema(description = "用户ID") + @QueryField(type = QueryType.EQ) + private Integer userId; + +} diff --git a/src/main/java/com/gxwebsoft/app/param/AppEventParam.java b/src/main/java/com/gxwebsoft/app/param/AppEventParam.java new file mode 100644 index 0000000..685d390 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/param/AppEventParam.java @@ -0,0 +1,63 @@ +package com.gxwebsoft.app.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-28 21:29:44 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "AppEventParam对象", description = "应用操作动态查询参数") +public class AppEventParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @QueryField(type = QueryType.EQ) + private Long id; + + @Schema(description = "关联应用ID") + @QueryField(type = QueryType.EQ) + private Long websiteId; + + @Schema(description = "事件类型: created/published/updated/domain_bound/member_added/status_changed") + @QueryField(type = QueryType.EQ) + private String eventType; + + @Schema(description = "事件标题(模糊)") + private String title; + + @Schema(description = "操作人用户ID") + @QueryField(type = QueryType.EQ) + private Long operatorId; + + @Schema(description = "关联ID,如版本ID") + @QueryField(type = QueryType.EQ) + private Long refId; + + @Schema(description = "关联类型") + @QueryField(type = QueryType.EQ) + private String refType; + + @Schema(description = "状态, 0正常, 1冻结") + @QueryField(type = QueryType.EQ) + private Integer status; + + @Schema(description = "用户ID") + @QueryField(type = QueryType.EQ) + private Integer userId; + + @Schema(description = "租户ID") + @QueryField(type = QueryType.EQ) + private Integer tenantId; + +} diff --git a/src/main/java/com/gxwebsoft/app/param/AppResourceParam.java b/src/main/java/com/gxwebsoft/app/param/AppResourceParam.java new file mode 100644 index 0000000..bc18e73 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/param/AppResourceParam.java @@ -0,0 +1,54 @@ +package com.gxwebsoft.app.param; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 开发者资源查询参数 + * + * @author 科技小王子 + * @since 2026-03-31 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "AppResourceParam对象", description = "开发者资源查询参数") +public class AppResourceParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "资源ID") + @QueryField(type = QueryType.EQ) + private Long resourceId; + + @Schema(description = "资源类型: server/database/storage/domain/ssl") + @QueryField(type = QueryType.EQ) + private String resourceType; + + @Schema(description = "关联应用ID") + @QueryField(type = QueryType.EQ) + private Long websiteId; + + @Schema(description = "服务商") + @QueryField(type = QueryType.EQ) + private String provider; + + @Schema(description = "状态") + @QueryField(type = QueryType.EQ) + private String status; + + @Schema(description = "所属用户ID") + @QueryField(type = QueryType.EQ) + private Integer userId; + + @Schema(description = "租户ID") + @QueryField(type = QueryType.EQ) + private Integer tenantId; + + @Schema(description = "关键词(名称/IP/域名/Host模糊搜索)") + private String keywords; +} diff --git a/src/main/java/com/gxwebsoft/app/param/AppTicketParam.java b/src/main/java/com/gxwebsoft/app/param/AppTicketParam.java new file mode 100644 index 0000000..a10144c --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/param/AppTicketParam.java @@ -0,0 +1,33 @@ +package com.gxwebsoft.app.param; + +import com.gxwebsoft.common.core.web.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 工单查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "工单查询参数") +public class AppTicketParam extends PageParam { + + @Schema(description = "关联应用ID") + private Long websiteId; + + @Schema(description = "工单状态") + private String status; + + @Schema(description = "工单分类") + private String category; + + @Schema(description = "优先级") + private String priority; + + @Schema(description = "处理人ID(传0=未分配)") + private Integer assigneeId; + + @Schema(description = "关键词(标题/工单编号)") + private String keywords; +} diff --git a/src/main/java/com/gxwebsoft/app/param/AppUserParam.java b/src/main/java/com/gxwebsoft/app/param/AppUserParam.java new file mode 100644 index 0000000..cc6adea --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/param/AppUserParam.java @@ -0,0 +1,59 @@ +package com.gxwebsoft.app.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-28 21:29:44 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "AppUserParam对象", description = "应用成员查询参数") +public class AppUserParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @QueryField(type = QueryType.EQ) + private Long id; + + @Schema(description = "关联应用ID") + @QueryField(type = QueryType.EQ) + private Long websiteId; + + @Schema(description = "用户ID") + @QueryField(type = QueryType.EQ) + private Integer userId; + + @Schema(description = "用户名(模糊搜索)") + private String username; + + @Schema(description = "角色: owner/admin/developer/viewer") + @QueryField(type = QueryType.EQ) + private String role; + + @Schema(description = "邀请人用户ID") + @QueryField(type = QueryType.EQ) + private Long inviteBy; + + @Schema(description = "排序(数字越小越靠前)") + @QueryField(type = QueryType.EQ) + private Integer sortNumber; + + @Schema(description = "状态, 0正常, 1冻结") + @QueryField(type = QueryType.EQ) + private Integer status; + + @Schema(description = "租户ID") + @QueryField(type = QueryType.EQ) + private Integer tenantId; + +} diff --git a/src/main/java/com/gxwebsoft/app/param/AppVersionParam.java b/src/main/java/com/gxwebsoft/app/param/AppVersionParam.java new file mode 100644 index 0000000..bf70f1a --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/param/AppVersionParam.java @@ -0,0 +1,63 @@ +package com.gxwebsoft.app.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-28 21:29:44 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "AppVersionParam对象", description = "应用版本发布记录查询参数") +public class AppVersionParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "自增ID") + @QueryField(type = QueryType.EQ) + private Long id; + + @Schema(description = "关联应用ID") + @QueryField(type = QueryType.EQ) + private Long websiteId; + + @Schema(description = "版本号,如 1.0.0") + @QueryField(type = QueryType.EQ) + private String versionNo; + + @Schema(description = "版本名称(模糊)") + private String versionName; + + @Schema(description = "环境: development/staging/production") + @QueryField(type = QueryType.EQ) + private String env; + + @Schema(description = "状态 0=构建中 1=已发布 2=已回滚 3=构建失败") + @QueryField(type = QueryType.EQ) + private Integer status; + + @Schema(description = "是否为当前版本") + @QueryField(type = QueryType.EQ) + private Boolean isCurrent; + + @Schema(description = "发布人用户ID") + @QueryField(type = QueryType.EQ) + private Long publishBy; + + @Schema(description = "用户ID") + @QueryField(type = QueryType.EQ) + private Integer userId; + + @Schema(description = "租户ID") + @QueryField(type = QueryType.EQ) + private Integer tenantId; + +} diff --git a/src/main/java/com/gxwebsoft/app/service/AppConfigService.java b/src/main/java/com/gxwebsoft/app/service/AppConfigService.java new file mode 100644 index 0000000..287d314 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/AppConfigService.java @@ -0,0 +1,58 @@ +package com.gxwebsoft.app.service; + +import com.gxwebsoft.app.entity.AppConfig; +import com.gxwebsoft.app.param.AppConfigParam; + +import java.util.List; +import java.util.Map; + +/** + * 应用配置表 Service + */ +public interface AppConfigService { + + /** + * 分页查询应用配置 + */ + List page(AppConfigParam param); + + /** + * 获取应用配置列表 + */ + List list(AppConfigParam param); + + /** + * 根据应用ID获取配置映射(自动解密) + */ + Map getConfigsByWebsiteId(Integer websiteId); + + /** + * 获取单个配置值 + */ + String getConfigValue(Integer websiteId, String configKey); + + /** + * 保存配置 + */ + void saveConfig(AppConfig config); + + /** + * 批量保存配置 + */ + void batchSaveConfig(Integer websiteId, List configs); + + /** + * 更新配置 + */ + void updateConfig(AppConfig config); + + /** + * 删除配置 + */ + void deleteConfig(Integer configId); + + /** + * 根据应用ID删除所有配置 + */ + void deleteByWebsiteId(Integer websiteId); +} diff --git a/src/main/java/com/gxwebsoft/app/service/AppCredentialService.java b/src/main/java/com/gxwebsoft/app/service/AppCredentialService.java new file mode 100644 index 0000000..ddd5d66 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/AppCredentialService.java @@ -0,0 +1,57 @@ +package com.gxwebsoft.app.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.app.entity.AppCredential; +import com.gxwebsoft.app.param.AppCredentialParam; + +import java.util.List; + +/** + * 应用密钥凭证Service + * + * @author 科技小王子 + * @since 2026-03-28 21:29:43 + */ +public interface AppCredentialService extends IService { + + /** + * 分页关联查询 + */ + PageResult pageRel(AppCredentialParam param); + + /** + * 关联查询全部 + */ + List listRel(AppCredentialParam param); + + /** + * 根据id查询 + */ + AppCredential getByIdRel(Integer id); + + /** + * 创建凭证(自动生成 appId 和 appSecret) + * + * @param credential 凭证基础信息 + * @return 包含明文 appSecret 的凭证对象(仅此次返回,之后不再展示) + */ + AppCredential createCredential(AppCredential credential); + + /** + * 重置密钥(重新生成 appSecret) + * + * @param id 凭证ID + * @return 包含新明文 appSecret 的凭证对象 + */ + AppCredential resetSecret(Long id); + + /** + * 禁用/启用凭证 + * + * @param id 凭证ID + * @param status 0=正常 1=冻结 + */ + boolean updateStatus(Long id, Integer status); + +} diff --git a/src/main/java/com/gxwebsoft/app/service/AppEventService.java b/src/main/java/com/gxwebsoft/app/service/AppEventService.java new file mode 100644 index 0000000..d0a8b0f --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/AppEventService.java @@ -0,0 +1,60 @@ +package com.gxwebsoft.app.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.app.entity.AppEvent; +import com.gxwebsoft.app.param.AppEventParam; + +import java.util.List; + +/** + * 应用操作动态Service + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +public interface AppEventService extends IService { + + /** + * 分页关联查询 + */ + PageResult pageRel(AppEventParam param); + + /** + * 关联查询全部 + */ + List listRel(AppEventParam param); + + /** + * 根据id查询 + */ + AppEvent getByIdRel(Integer id); + + /** + * 记录应用事件(便捷方法,供其他模块调用) + * + * @param websiteId 应用ID + * @param eventType 事件类型(created/published/updated/domain_bound/member_added/status_changed 等) + * @param title 事件标题 + * @param content 详细描述 + * @param operatorId 操作人用户ID + * @param operatorName 操作人名称 + * @param refId 关联记录ID(如版本ID) + * @param refType 关联记录类型 + */ + void logEvent(Long websiteId, String eventType, String title, String content, + Long operatorId, String operatorName, Long refId, String refType); + + /** + * 快捷记录事件(无关联记录) + */ + void logEvent(Long websiteId, String eventType, String title, Long operatorId, String operatorName); + + /** + * 获取指定应用的最新一条事件(用于卡片"最新动态"展示) + * + * @param websiteId 应用ID + */ + AppEvent getLatestEvent(Long websiteId); + +} diff --git a/src/main/java/com/gxwebsoft/app/service/AppResourceService.java b/src/main/java/com/gxwebsoft/app/service/AppResourceService.java new file mode 100644 index 0000000..76f4057 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/AppResourceService.java @@ -0,0 +1,39 @@ +package com.gxwebsoft.app.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.app.entity.AppResource; +import com.gxwebsoft.app.param.AppResourceParam; +import com.gxwebsoft.common.core.web.PageResult; + +import java.util.List; +import java.util.Map; + +/** + * 开发者资源 Service + * + * @author 科技小王子 + * @since 2026-03-31 + */ +public interface AppResourceService extends IService { + + /** 分页查询(关联应用名称) */ + PageResult pageRel(AppResourceParam param); + + /** 列表查询 */ + List listRel(AppResourceParam param); + + /** 详情(关联查询) */ + AppResource getByIdRel(Long resourceId); + + /** 新增资源 */ + AppResource addResource(AppResource resource, Integer userId); + + /** 更新资源 */ + AppResource updateResource(AppResource resource); + + /** 删除资源(逻辑删除) */ + void removeResource(Long resourceId, Integer userId); + + /** 按资源类型统计数量 [{resourceType, cnt}] */ + Map countByType(Integer userId, Integer tenantId); +} diff --git a/src/main/java/com/gxwebsoft/app/service/AppTicketService.java b/src/main/java/com/gxwebsoft/app/service/AppTicketService.java new file mode 100644 index 0000000..d81bf6e --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/AppTicketService.java @@ -0,0 +1,46 @@ +package com.gxwebsoft.app.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.app.entity.AppTicket; +import com.gxwebsoft.app.entity.AppTicketReply; +import com.gxwebsoft.app.param.AppTicketParam; +import com.gxwebsoft.common.core.web.PageResult; + +import java.util.List; +import java.util.Map; + +/** + * 应用工单 Service + */ +public interface AppTicketService extends IService { + + /** 客户端:查自己提交的工单(分页) */ + PageResult myPage(AppTicketParam param, Integer userId); + + /** 技术端:查询所有工单(分页) */ + PageResult allPage(AppTicketParam param); + + /** 提交工单(自动分配) */ + AppTicket submit(AppTicket ticket, Integer userId); + + /** 更新状态(技术人员操作) */ + void updateStatus(Long ticketId, String status, Integer operatorId); + + /** 分配处理人 */ + void assign(Long ticketId, Integer assigneeId); + + /** 关闭工单(提交人操作) */ + void closeByUser(Long ticketId, Integer userId); + + /** 获取工单回复列表 */ + List getReplies(Long ticketId); + + /** 添加回复 */ + AppTicketReply addReply(AppTicketReply reply, Integer userId); + + /** 统计数据 */ + Map stats(Long websiteId, Integer userId); + + /** 获取技术人员列表(角色:developer/admin 的应用成员) */ + List> getTechStaffList(); +} diff --git a/src/main/java/com/gxwebsoft/app/service/AppUserService.java b/src/main/java/com/gxwebsoft/app/service/AppUserService.java new file mode 100644 index 0000000..0aa0793 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/AppUserService.java @@ -0,0 +1,68 @@ +package com.gxwebsoft.app.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.app.entity.AppUser; +import com.gxwebsoft.app.param.AppUserParam; + +import java.util.List; + +/** + * 应用成员Service + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +public interface AppUserService extends IService { + + /** + * 分页关联查询 + */ + PageResult pageRel(AppUserParam param); + + /** + * 关联查询全部 + */ + List listRel(AppUserParam param); + + /** + * 根据id查询 + */ + AppUser getByIdRel(Integer id); + + /** + * 邀请成员加入应用 + * + * @param websiteId 应用ID + * @param userId 被邀请的用户ID + * @param role 分配的角色 + * @param inviteBy 邀请人用户ID + * @return 成员记录 + */ + AppUser inviteUser(Long websiteId, Integer userId, String role, Integer inviteBy); + + /** + * 修改成员角色 + * + * @param id 成员记录ID + * @param role 新角色 + */ + boolean updateRole(Long id, String role); + + /** + * 检查用户是否已是应用成员 + * + * @param websiteId 应用ID + * @param userId 用户ID + */ + boolean isMember(Long websiteId, Integer userId); + + /** + * 根据手机号查找系统用户(用于手机号邀请) + * + * @param phone 手机号 + * @return 系统用户,不存在返回 null + */ + com.gxwebsoft.common.system.entity.User findUserByPhone(String phone); + +} diff --git a/src/main/java/com/gxwebsoft/app/service/AppVersionService.java b/src/main/java/com/gxwebsoft/app/service/AppVersionService.java new file mode 100644 index 0000000..bbfa879 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/AppVersionService.java @@ -0,0 +1,56 @@ +package com.gxwebsoft.app.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.app.entity.AppVersion; +import com.gxwebsoft.app.param.AppVersionParam; + +import java.util.List; + +/** + * 应用版本发布记录Service + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +public interface AppVersionService extends IService { + + /** + * 分页关联查询 + */ + PageResult pageRel(AppVersionParam param); + + /** + * 关联查询全部 + */ + List listRel(AppVersionParam param); + + /** + * 根据id查询 + */ + AppVersion getByIdRel(Integer id); + + /** + * 发布版本(将此版本设为当前版本,其他版本 isCurrent=false) + * + * @param id 版本ID + * @param publishBy 发布人用户ID + */ + boolean publish(Long id, Integer publishBy); + + /** + * 回滚到指定版本(将此版本设为当前版本,当前版本标为已回滚) + * + * @param id 要回滚到的版本ID + * @param publishBy 操作人用户ID + */ + boolean rollback(Long id, Integer publishBy); + + /** + * 获取指定应用的当前版本 + * + * @param websiteId 应用ID + */ + AppVersion getCurrentVersion(Long websiteId); + +} diff --git a/src/main/java/com/gxwebsoft/app/service/impl/AppConfigServiceImpl.java b/src/main/java/com/gxwebsoft/app/service/impl/AppConfigServiceImpl.java new file mode 100644 index 0000000..fa8464e --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/impl/AppConfigServiceImpl.java @@ -0,0 +1,270 @@ +package com.gxwebsoft.app.service.impl; + +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.symmetric.AES; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.app.entity.AppConfig; +import com.gxwebsoft.app.mapper.AppConfigMapper; +import com.gxwebsoft.app.param.AppConfigParam; +import com.gxwebsoft.app.service.AppConfigService; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 应用配置表 Service 实现类 + * + * @author 科技小王子 + */ +@Slf4j +@Service +public class AppConfigServiceImpl extends ServiceImpl implements AppConfigService { + + @Resource + private UserService userService; + + /** + * 配置加密密钥(从配置文件读取) + */ + @Value("${app.config.encrypt-key:GXWebsoft2024!@#$}") + private String encryptKey; + + /** + * 分页查询应用配置 + */ + @Override + public List page(AppConfigParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("sort_number asc, config_id asc"); + List list = baseMapper.selectPageRel(page, param); + return page.sortRecords(list); + } + + /** + * 分页查询应用配置(返回PageResult) + */ + public PageResult pageRel(AppConfigParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("sort_number asc, config_id asc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + /** + * 获取应用配置列表 + */ + @Override + public List list(AppConfigParam param) { + List list = baseMapper.selectListRel(param); + PageParam page = new PageParam<>(); + page.setDefaultOrder("sort_number asc, config_id asc"); + return page.sortRecords(list); + } + + /** + * 根据应用ID获取配置映射(自动解密) + */ + @Override + public Map getConfigsByWebsiteId(Integer websiteId) { + List> configs = baseMapper.selectConfigsByWebsiteId(websiteId); + Map result = new HashMap<>(); + + for (Map config : configs) { + String configKey = (String) config.get("configKey"); + Object configValue = config.get("configValue"); + Integer isEncrypted = (Integer) config.get("isEncrypted"); + + // 解密 + if (isEncrypted != null && isEncrypted == 1 && configValue != null) { + try { + configValue = decrypt((String) configValue); + } catch (Exception e) { + log.error("配置解密失败: {}", configKey, e); + } + } + + result.put(configKey, configValue); + } + + return result; + } + + /** + * 获取单个配置值 + */ + @Override + public String getConfigValue(Integer websiteId, String configKey) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AppConfig::getWebsiteId, websiteId); + wrapper.eq(AppConfig::getConfigKey, configKey); + AppConfig config = getOne(wrapper); + + if (config == null || config.getConfigValue() == null) { + return null; + } + + // 解密 + if (config.getIsEncrypted() != null && config.getIsEncrypted() == 1) { + return decrypt(config.getConfigValue()); + } + + return config.getConfigValue(); + } + + /** + * 保存配置 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void saveConfig(AppConfig config) { + // 设置租户ID + Integer tenantId = getCurrentTenantId(); + if (tenantId != null) { + config.setTenantId(Long.valueOf(tenantId)); + } + + // 加密敏感信息 + if (config.getIsEncrypted() != null && config.getIsEncrypted() == 1 && config.getConfigValue() != null) { + config.setConfigValue(encrypt(config.getConfigValue())); + } + + save(config); + } + + /** + * 批量保存配置 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void batchSaveConfig(Integer websiteId, List configs) { + // 设置租户ID + Integer tenantId = getCurrentTenantId(); + if (tenantId != null) { + for (AppConfig config : configs) { + config.setTenantId(Long.valueOf(tenantId)); + } + } + + // 先删除该应用的所有配置 + remove(new LambdaQueryWrapper() + .eq(AppConfig::getWebsiteId, websiteId)); + + // 批量插入新配置 + for (AppConfig config : configs) { + config.setWebsiteId(websiteId); + + // 加密敏感信息 + if (config.getIsEncrypted() != null && config.getIsEncrypted() == 1 && config.getConfigValue() != null) { + config.setConfigValue(encrypt(config.getConfigValue())); + } + + save(config); + } + } + + /** + * 更新配置 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void updateConfig(AppConfig config) { + // 加密敏感信息 + if (config.getIsEncrypted() != null && config.getIsEncrypted() == 1 && config.getConfigValue() != null) { + config.setConfigValue(encrypt(config.getConfigValue())); + } + + updateById(config); + } + + /** + * 删除配置 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteConfig(Integer configId) { + removeById(configId); + } + + /** + * 根据应用ID删除所有配置 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteByWebsiteId(Integer websiteId) { + remove(new LambdaQueryWrapper() + .eq(AppConfig::getWebsiteId, websiteId)); + } + + /** + * 构建查询条件 + */ + private LambdaQueryWrapper buildQueryWrapper(AppConfigParam param) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (param.getConfigId() != null) { + wrapper.eq(AppConfig::getConfigId, param.getConfigId()); + } + if (param.getWebsiteId() != null) { + wrapper.eq(AppConfig::getWebsiteId, param.getWebsiteId()); + } + if (param.getConfigKey() != null) { + wrapper.like(AppConfig::getConfigKey, param.getConfigKey()); + } + if (param.getConfigType() != null) { + wrapper.eq(AppConfig::getConfigType, param.getConfigType()); + } + if (param.getIsSecret() != null) { + wrapper.eq(AppConfig::getIsSecret, param.getIsSecret()); + } + + wrapper.orderByAsc(AppConfig::getConfigType) + .orderByAsc(AppConfig::getSortNumber) + .orderByAsc(AppConfig::getConfigId); + + return wrapper; + } + + /** + * AES 加密 + */ + private String encrypt(String plainText) { + AES aes = SecureUtil.aes(encryptKey.getBytes()); + return aes.encryptBase64(plainText); + } + + /** + * AES 解密 + */ + private String decrypt(String cipherText) { + AES aes = SecureUtil.aes(encryptKey.getBytes()); + return aes.decryptStr(cipherText); + } + + /** + * 获取当前登录用户的租户ID + */ + private Integer getCurrentTenantId() { + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof User) { + return ((User) authentication.getPrincipal()).getTenantId(); + } + } catch (Exception e) { + log.error("获取当前用户租户ID失败", e); + } + return null; + } +} diff --git a/src/main/java/com/gxwebsoft/app/service/impl/AppCredentialServiceImpl.java b/src/main/java/com/gxwebsoft/app/service/impl/AppCredentialServiceImpl.java new file mode 100644 index 0000000..cf2de6b --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/impl/AppCredentialServiceImpl.java @@ -0,0 +1,125 @@ +package com.gxwebsoft.app.service.impl; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.app.mapper.AppCredentialMapper; +import com.gxwebsoft.app.service.AppCredentialService; +import com.gxwebsoft.app.entity.AppCredential; +import com.gxwebsoft.app.param.AppCredentialParam; +import com.gxwebsoft.common.core.utils.CommonUtil; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 应用密钥凭证Service实现 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:43 + */ +@Slf4j +@Service +public class AppCredentialServiceImpl extends ServiceImpl implements AppCredentialService { + + @Override + public PageResult pageRel(AppCredentialParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("sort_number asc, create_time desc"); + List list = baseMapper.selectPageRel(page, param); + // 脱敏处理:appSecret 不对外展示(仅创建/重置时返回明文) + list.forEach(this::maskSecret); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(AppCredentialParam param) { + List list = baseMapper.selectListRel(param); + PageParam page = new PageParam<>(); + page.setDefaultOrder("sort_number asc, create_time desc"); + list = page.sortRecords(list); + list.forEach(this::maskSecret); + return list; + } + + @Override + public AppCredential getByIdRel(Integer id) { + AppCredentialParam param = new AppCredentialParam(); + param.setId(id.longValue()); + AppCredential credential = param.getOne(baseMapper.selectListRel(param)); + if (credential != null) { + maskSecret(credential); + } + return credential; + } + + @Override + public AppCredential createCredential(AppCredential credential) { + // 生成 AppID:app_ + 16位随机字符串 + String appId = "app_" + CommonUtil.randomUUID16(); + // 生成 AppSecret:32位随机字符串(大小写字母+数字) + String appSecret = generateSecretValue(); + + credential.setAppId(appId); + credential.setAppSecret(appSecret); + // 默认状态正常 + if (credential.getStatus() == null) { + credential.setStatus(0); + } + if (StrUtil.isBlank(credential.getType())) { + credential.setType("server"); + } + + save(credential); + log.info("创建凭证成功,websiteId={}, appId={}", credential.getWebsiteId(), appId); + // 返回含明文 secret 的对象(仅此一次) + return credential; + } + + @Override + public AppCredential resetSecret(Long id) { + AppCredential credential = getById(id); + if (credential == null) { + throw new RuntimeException("凭证不存在"); + } + String newSecret = generateSecretValue(); + credential.setAppSecret(newSecret); + updateById(credential); + log.info("重置凭证密钥成功,id={}", id); + return credential; + } + + @Override + public boolean updateStatus(Long id, Integer status) { + return update(new LambdaUpdateWrapper() + .eq(AppCredential::getId, id) + .set(AppCredential::getStatus, status)); + } + + /** + * 生成32位随机 AppSecret + */ + private String generateSecretValue() { + // 格式:8位-8位-8位-8位(类似 UUID 格式,便于复制) + return RandomUtil.randomString(8) + "-" + + RandomUtil.randomString(8) + "-" + + RandomUtil.randomString(8) + "-" + + RandomUtil.randomString(8); + } + + /** + * 脱敏处理:将 appSecret 替换为掩码 + */ + private void maskSecret(AppCredential credential) { + if (StrUtil.isNotBlank(credential.getAppSecret())) { + // 只保留前4位,其余用 * 替代 + String secret = credential.getAppSecret(); + credential.setAppSecret(secret.substring(0, Math.min(4, secret.length())) + "**********************"); + } + } + +} diff --git a/src/main/java/com/gxwebsoft/app/service/impl/AppEventServiceImpl.java b/src/main/java/com/gxwebsoft/app/service/impl/AppEventServiceImpl.java new file mode 100644 index 0000000..bcdb7c8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/impl/AppEventServiceImpl.java @@ -0,0 +1,86 @@ +package com.gxwebsoft.app.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.app.mapper.AppEventMapper; +import com.gxwebsoft.app.service.AppEventService; +import com.gxwebsoft.app.entity.AppEvent; +import com.gxwebsoft.app.param.AppEventParam; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 应用操作动态Service实现 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Slf4j +@Service +public class AppEventServiceImpl extends ServiceImpl implements AppEventService { + + @Override + public PageResult pageRel(AppEventParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("create_time desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(AppEventParam param) { + List list = baseMapper.selectListRel(param); + PageParam page = new PageParam<>(); + page.setDefaultOrder("create_time desc"); + return page.sortRecords(list); + } + + @Override + public AppEvent getByIdRel(Integer id) { + AppEventParam param = new AppEventParam(); + param.setId(id.longValue()); + return param.getOne(baseMapper.selectListRel(param)); + } + + @Override + @Async + public void logEvent(Long websiteId, String eventType, String title, String content, + Long operatorId, String operatorName, Long refId, String refType) { + try { + AppEvent event = new AppEvent(); + event.setWebsiteId(websiteId); + event.setEventType(eventType); + event.setTitle(title); + event.setContent(content); + event.setOperatorId(operatorId); + event.setOperator(operatorName); + event.setRefId(refId); + event.setRefType(refType); + event.setStatus(0); + save(event); + log.debug("记录事件成功,websiteId={}, eventType={}, title={}", websiteId, eventType, title); + } catch (Exception e) { + log.warn("记录事件失败,websiteId={}, eventType={}: {}", websiteId, eventType, e.getMessage()); + } + } + + @Override + @Async + public void logEvent(Long websiteId, String eventType, String title, Long operatorId, String operatorName) { + logEvent(websiteId, eventType, title, null, operatorId, operatorName, null, null); + } + + @Override + public AppEvent getLatestEvent(Long websiteId) { + return getOne(new LambdaQueryWrapper() + .eq(AppEvent::getWebsiteId, websiteId) + .orderByDesc(AppEvent::getCreateTime) + .last("LIMIT 1")); + } + +} diff --git a/src/main/java/com/gxwebsoft/app/service/impl/AppResourceServiceImpl.java b/src/main/java/com/gxwebsoft/app/service/impl/AppResourceServiceImpl.java new file mode 100644 index 0000000..dc3aa96 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/impl/AppResourceServiceImpl.java @@ -0,0 +1,109 @@ +package com.gxwebsoft.app.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.app.entity.AppResource; +import com.gxwebsoft.app.mapper.AppResourceMapper; +import com.gxwebsoft.app.param.AppResourceParam; +import com.gxwebsoft.app.service.AppResourceService; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 开发者资源 Service 实现 + * + * @author 科技小王子 + * @since 2026-03-31 + */ +@Slf4j +@Service +public class AppResourceServiceImpl extends ServiceImpl + implements AppResourceService { + + @Override + public PageResult pageRel(AppResourceParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("create_time desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(AppResourceParam param) { + return baseMapper.selectListRel(param); + } + + @Override + public AppResource getByIdRel(Long resourceId) { + AppResourceParam param = new AppResourceParam(); + param.setResourceId(resourceId); + List list = baseMapper.selectListRel(param); + return list.isEmpty() ? null : list.get(0); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AppResource addResource(AppResource resource, Integer userId) { + resource.setUserId(userId); + resource.setDeleted(0); + resource.setCreateTime(LocalDateTime.now()); + resource.setUpdateTime(LocalDateTime.now()); + // 默认状态 + if (resource.getStatus() == null) { + resource.setStatus("running"); + } + save(resource); + log.info("新增资源成功, type={}, name={}, userId={}", resource.getResourceType(), resource.getName(), userId); + return resource; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public AppResource updateResource(AppResource resource) { + if (resource.getResourceId() == null) { + throw new RuntimeException("资源ID不能为空"); + } + resource.setUpdateTime(LocalDateTime.now()); + updateById(resource); + return getByIdRel(resource.getResourceId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeResource(Long resourceId, Integer userId) { + AppResource resource = getById(resourceId); + if (resource == null) { + throw new RuntimeException("资源不存在"); + } + if (!resource.getUserId().equals(userId)) { + throw new RuntimeException("无权操作此资源"); + } + resource.setDeleted(1); + resource.setUpdateTime(LocalDateTime.now()); + updateById(resource); + log.info("删除资源成功, resourceId={}, userId={}", resourceId, userId); + } + + @Override + public Map countByType(Integer userId, Integer tenantId) { + List> raw = baseMapper.countByType(userId, tenantId); + Map result = new HashMap<>(); + // 初始化所有类型为 0 + for (String type : new String[]{"server", "database", "storage", "domain", "ssl"}) { + result.put(type, 0L); + } + for (Map row : raw) { + String type = (String) row.get("resourceType"); + Long cnt = ((Number) row.get("cnt")).longValue(); + result.put(type, cnt); + } + return result; + } +} diff --git a/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java b/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java new file mode 100644 index 0000000..f351c76 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java @@ -0,0 +1,579 @@ +package com.gxwebsoft.app.service.impl; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.app.entity.AppTicket; +import com.gxwebsoft.app.entity.AppTicketReply; +import com.gxwebsoft.app.entity.AppUser; +import com.gxwebsoft.app.mapper.AppTicketMapper; +import com.gxwebsoft.app.mapper.AppTicketReplyMapper; +import com.gxwebsoft.app.param.AppTicketParam; +import com.gxwebsoft.app.service.AppTicketService; +import com.gxwebsoft.app.service.AppUserService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 应用工单 Service 实现 + * + * @author 科技小王子 + * @since 2026-03-30 + */ +@Slf4j +@Service +public class AppTicketServiceImpl extends ServiceImpl implements AppTicketService { + + /** 企业微信群机器人 Webhook,留空则不发送 */ + @Value("${notify.wecom-webhook:}") + private String wecomWebhook; + + /** 飞书群机器人 Webhook,留空则不发送 */ + @Value("${notify.feishu-webhook:}") + private String feishuWebhook; + + @Resource + private AppTicketReplyMapper replyMapper; + + @Resource + private AppUserService appUserService; + + @Resource + private UserService userService; + + // ─── 生成工单编号 ───────────────────────────────────────────── + private String generateTicketNo() { + String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + String rand = String.format("%04d", new Random().nextInt(10000)); + return "TK-" + ts + rand; + } + + // ─── 客户端:查自己的工单 ───────────────────────────────────── + @Override + public PageResult myPage(AppTicketParam param, Integer userId) { + // 用 PageParam 包装分页参数(getCurrent/getSize 来自父类 Page) + Page page = new Page<>(param.getCurrent(), param.getSize() > 0 ? param.getSize() : 15); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(AppTicket::getDeleted, 0) + .eq(AppTicket::getSubmitUserId, userId) + .eq(ObjectUtil.isNotNull(param.getWebsiteId()), AppTicket::getWebsiteId, param.getWebsiteId()) + .eq(ObjectUtil.isNotEmpty(param.getStatus()), AppTicket::getStatus, param.getStatus()) + .eq(ObjectUtil.isNotEmpty(param.getCategory()), AppTicket::getCategory, param.getCategory()) + .and(ObjectUtil.isNotEmpty(param.getKeywords()), q -> + q.like(AppTicket::getTitle, param.getKeywords()) + .or().like(AppTicket::getTicketNo, param.getKeywords())) + // 状态优先级:pending > assigned > processing > resolved > closed;同状态内最新的排前 + .last("ORDER BY FIELD(status,'pending','assigned','processing','resolved','closed','rejected'), create_time DESC"); + + baseMapper.selectPage(page, wrapper); + return new PageResult<>(page.getRecords(), page.getTotal()); + } + + // ─── 技术端:查所有工单 ─────────────────────────────────────── + @Override + public PageResult allPage(AppTicketParam param) { + Page page = new Page<>(param.getCurrent(), param.getSize() > 0 ? param.getSize() : 20); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(AppTicket::getDeleted, 0) + .eq(ObjectUtil.isNotNull(param.getWebsiteId()), AppTicket::getWebsiteId, param.getWebsiteId()) + .eq(ObjectUtil.isNotEmpty(param.getStatus()), AppTicket::getStatus, param.getStatus()) + .eq(ObjectUtil.isNotEmpty(param.getCategory()), AppTicket::getCategory, param.getCategory()) + .eq(ObjectUtil.isNotEmpty(param.getPriority()), AppTicket::getPriority, param.getPriority()) + .and(ObjectUtil.isNotNull(param.getAssigneeId()) && param.getAssigneeId() == 0, + q -> q.isNull(AppTicket::getAssigneeId)) + .eq(ObjectUtil.isNotNull(param.getAssigneeId()) && param.getAssigneeId() > 0, + AppTicket::getAssigneeId, param.getAssigneeId()) + .and(ObjectUtil.isNotEmpty(param.getKeywords()), q -> + q.like(AppTicket::getTitle, param.getKeywords()) + .or().like(AppTicket::getTicketNo, param.getKeywords())) + // 状态优先级 > 紧急程度 > 最新提交时间 + .last("ORDER BY FIELD(status,'pending','assigned','processing','resolved','closed','rejected'), FIELD(priority,'urgent','high','normal','low'), create_time DESC"); + + baseMapper.selectPage(page, wrapper); + return new PageResult<>(page.getRecords(), page.getTotal()); + } + + // ─── 提交工单(自动分配) ───────────────────────────────────── + @Override + @Transactional(rollbackFor = Exception.class) + public AppTicket submit(AppTicket ticket, Integer userId) { + // 补充提交人信息 + ticket.setSubmitUserId(userId); + ticket.setTicketNo(generateTicketNo()); + ticket.setStatus("pending"); + ticket.setReplyCount(0); + ticket.setDeleted(0); + ticket.setCreateTime(LocalDateTime.now()); + ticket.setUpdateTime(LocalDateTime.now()); + + // 从用户服务取昵称/头像冗余存储(@InterceptorIgnore 绕过租户拦截器跨库查询) + try { + User user = userService.getByIdIgnoreTenant(userId); + if (user != null) { + ticket.setSubmitUserName(user.getNickname() != null ? user.getNickname() : user.getUsername()); + ticket.setSubmitUserAvatar(user.getAvatar()); + } + } catch (Exception e) { + log.warn("获取提交人信息失败", e); + } + + // 自动分配:查找该应用的 admin/developer 成员,随机分配 + autoAssign(ticket); + + save(ticket); + + // 异步推送:新工单通知(通知分配到的技术人员) + sendTicketCreatedAsync(ticket); + + return ticket; + } + + /** 自动分配工单给应用的技术成员 */ + private void autoAssign(AppTicket ticket) { + if (ticket.getWebsiteId() == null) return; + try { + List members = appUserService.list( + new LambdaQueryWrapper() + .eq(AppUser::getWebsiteId, ticket.getWebsiteId()) + .eq(AppUser::getStatus, 0) + .in(AppUser::getRole, "admin", "developer", "owner")); + if (!members.isEmpty()) { + // 简单轮询:按工单数最少分配(此处随机) + AppUser assigned = members.get(new Random().nextInt(members.size())); + ticket.setAssigneeId(assigned.getUserId()); + ticket.setAssigneeName(assigned.getNickname() != null ? assigned.getNickname() : assigned.getUsername()); + ticket.setAssigneeAvatar(assigned.getAvatar()); + ticket.setStatus("assigned"); + } + } catch (Exception e) { + log.warn("自动分配工单失败,保持 pending 状态", e); + } + } + + // ─── 更新状态 ───────────────────────────────────────────────── + @Override + public void updateStatus(Long ticketId, String status, Integer operatorId) { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .eq(AppTicket::getTicketId, ticketId) + .set(AppTicket::getStatus, status) + .set(AppTicket::getUpdateTime, LocalDateTime.now()); + if ("resolved".equals(status)) { + wrapper.set(AppTicket::getResolvedTime, LocalDateTime.now()); + // 如果没有分配人,自动将操作人设为处理人 + AppTicket t = getById(ticketId); + if (t != null && t.getAssigneeId() == null) { + wrapper.set(AppTicket::getAssigneeId, operatorId); + } + } + update(wrapper); + + // 异步推送:状态变更通知提交人(已解决/已关闭) + if ("resolved".equals(status) || "closed".equals(status)) { + AppTicket t = getById(ticketId); + if (t != null) sendStatusChangedAsync(t, status); + } + } + + // ─── 分配处理人 ─────────────────────────────────────────────── + @Override + public void assign(Long ticketId, Integer assigneeId) { + User user = userService.getByIdIgnoreTenant(assigneeId); + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .eq(AppTicket::getTicketId, ticketId) + .set(AppTicket::getAssigneeId, assigneeId) + .set(user != null, AppTicket::getAssigneeName, + user != null ? (user.getNickname() != null ? user.getNickname() : user.getUsername()) : null) + .set(user != null, AppTicket::getAssigneeAvatar, user != null ? user.getAvatar() : null) + .set(AppTicket::getStatus, "assigned") + .set(AppTicket::getUpdateTime, LocalDateTime.now()); + update(wrapper); + + // 异步推送:通知新分配到的技术人员 + AppTicket t = getById(ticketId); + if (t != null) sendTicketAssignedAsync(t); + } + + // ─── 用户关闭工单 ───────────────────────────────────────────── + @Override + public void closeByUser(Long ticketId, Integer userId) { + AppTicket ticket = getById(ticketId); + if (ticket == null || !ticket.getSubmitUserId().equals(userId)) { + throw new RuntimeException("无权操作该工单"); + } + update(new LambdaUpdateWrapper() + .eq(AppTicket::getTicketId, ticketId) + .set(AppTicket::getStatus, "closed") + .set(AppTicket::getClosedTime, LocalDateTime.now()) + .set(AppTicket::getUpdateTime, LocalDateTime.now())); + } + + // ─── 获取回复列表 ───────────────────────────────────────────── + @Override + public List getReplies(Long ticketId) { + return replyMapper.selectList( + new LambdaQueryWrapper() + .eq(AppTicketReply::getTicketId, ticketId) + .eq(AppTicketReply::getDeleted, 0) + .orderByAsc(AppTicketReply::getCreateTime)); + } + + // ─── 添加回复 ───────────────────────────────────────────────── + @Override + @Transactional(rollbackFor = Exception.class) + public AppTicketReply addReply(AppTicketReply reply, Integer userId) { + reply.setUserId(userId); + reply.setDeleted(0); + reply.setCreateTime(LocalDateTime.now()); + + // 补充用户信息(@InterceptorIgnore 绕过租户拦截器跨库查询) + try { + User user = userService.getByIdIgnoreTenant(userId); + if (user != null) { + reply.setUserName(user.getNickname() != null ? user.getNickname() : user.getUsername()); + reply.setUserAvatar(user.getAvatar()); + } + } catch (Exception e) { + log.warn("获取回复人信息失败", e); + } + + // 判断是否是技术人员(该应用的 admin/developer/owner) + AppTicket ticket = getById(reply.getTicketId()); + if (ticket != null) { + boolean isStaff = appUserService.count(new LambdaQueryWrapper() + .eq(AppUser::getWebsiteId, ticket.getWebsiteId()) + .eq(AppUser::getUserId, userId) + .in(AppUser::getRole, "owner", "admin", "developer") + .eq(AppUser::getStatus, 0)) > 0; + reply.setIsStaff(isStaff ? 1 : 0); + } else { + reply.setIsStaff(0); + } + + replyMapper.insert(reply); + + // 更新工单回复数 & 更新时间,若状态是 assigned 则推进为 processing + update(new LambdaUpdateWrapper() + .eq(AppTicket::getTicketId, reply.getTicketId()) + .setSql("reply_count = reply_count + 1") + .set(AppTicket::getUpdateTime, LocalDateTime.now()) + .eq(AppTicket::getStatus, "assigned") + .set(AppTicket::getStatus, "processing")); + + // 异步推送:有新回复时通知对方 + if (ticket != null) sendReplyNotifyAsync(ticket, reply); + + return reply; + } + + // ─── 统计 ───────────────────────────────────────────────────── + @Override + public Map stats(Long websiteId, Integer userId) { + LambdaQueryWrapper base = new LambdaQueryWrapper() + .eq(AppTicket::getDeleted, 0) + .eq(ObjectUtil.isNotNull(websiteId), AppTicket::getWebsiteId, websiteId) + .eq(ObjectUtil.isNotNull(userId), AppTicket::getSubmitUserId, userId); + + Map result = new HashMap<>(); + result.put("total", (long) count(base.clone())); + result.put("pending", (long) count(base.clone().in(AppTicket::getStatus, "pending", "assigned"))); + result.put("processing", (long) count(base.clone().eq(AppTicket::getStatus, "processing"))); + result.put("resolved", (long) count(base.clone().eq(AppTicket::getStatus, "resolved"))); + result.put("closed", (long) count(base.clone().eq(AppTicket::getStatus, "closed"))); + return result; + } + + // ─── 获取技术人员列表 ───────────────────────────────────────── + @Override + public List> getTechStaffList() { + List members = appUserService.list( + new LambdaQueryWrapper() + .eq(AppUser::getStatus, 0) + .in(AppUser::getRole, "owner", "admin", "developer") + .select(AppUser::getUserId, AppUser::getNickname, AppUser::getAvatar)); + + // 去重(一个用户可能在多个应用里) + Map dedupMap = new LinkedHashMap<>(); + for (AppUser m : members) { + dedupMap.put(m.getUserId(), m); + } + + return dedupMap.values().stream().map(m -> { + Map map = new HashMap<>(); + map.put("userId", m.getUserId()); + map.put("nickname", m.getNickname() != null ? m.getNickname() : "用户" + m.getUserId()); + map.put("avatar", m.getAvatar()); + return map; + }).collect(Collectors.toList()); + } + + // ════════════════════════════════════════════════════════════ + // 异步消息推送 + // ════════════════════════════════════════════════════════════ + + /** + * 场景1:新工单提交 → 通知分配到的技术人员 + */ + @Async + public void sendTicketCreatedAsync(AppTicket ticket) { + String now = now(); + String title = String.format("🎫 新工单待处理(%s)", now); + + String assigneeLine = StrUtil.isNotBlank(ticket.getAssigneeName()) + ? "**处理人:** " + ticket.getAssigneeName() + : "**处理人:** 待分配"; + + String wecomContent = String.format( + "## %s\n" + + "> **工单号:** %s\n" + + "> **标题:** %s\n" + + "> **分类:** %s **优先级:** %s\n" + + "> **提交人:** %s\n" + + "> %s\n" + + "> **描述:** %s", + title, + nullSafe(ticket.getTicketNo()), + nullSafe(ticket.getTitle()), + categoryLabel(ticket.getCategory()), priorityLabel(ticket.getPriority()), + nullSafe(ticket.getSubmitUserName()), + assigneeLine, + truncate(ticket.getContent(), 120) + ); + + List feishuLines = Arrays.asList( + new String[]{"工单号:" + nullSafe(ticket.getTicketNo())}, + new String[]{"标 题:" + nullSafe(ticket.getTitle())}, + new String[]{"分 类:" + categoryLabel(ticket.getCategory()) + " 优先级:" + priorityLabel(ticket.getPriority())}, + new String[]{"提交人:" + nullSafe(ticket.getSubmitUserName())}, + new String[]{assigneeLine.replace("**", "")}, + new String[]{"描 述:" + truncate(ticket.getContent(), 120)} + ); + + doSend(title, wecomContent, feishuLines, "ticket_created"); + } + + /** + * 场景2:工单被重新分配 → 通知新处理人 + */ + @Async + public void sendTicketAssignedAsync(AppTicket ticket) { + String now = now(); + String title = String.format("📋 工单已分配给您(%s)", now); + + String wecomContent = String.format( + "## %s\n" + + "> **工单号:** %s\n" + + "> **标题:** %s\n" + + "> **分配给:** %s\n" + + "> **分类:** %s **优先级:** %s\n" + + "> **描述:** %s", + title, + nullSafe(ticket.getTicketNo()), + nullSafe(ticket.getTitle()), + nullSafe(ticket.getAssigneeName()), + categoryLabel(ticket.getCategory()), priorityLabel(ticket.getPriority()), + truncate(ticket.getContent(), 100) + ); + + List feishuLines = Arrays.asList( + new String[]{"工单号:" + nullSafe(ticket.getTicketNo())}, + new String[]{"标 题:" + nullSafe(ticket.getTitle())}, + new String[]{"分配给:" + nullSafe(ticket.getAssigneeName())}, + new String[]{"分 类:" + categoryLabel(ticket.getCategory()) + " 优先级:" + priorityLabel(ticket.getPriority())}, + new String[]{"描 述:" + truncate(ticket.getContent(), 100)} + ); + + doSend(title, wecomContent, feishuLines, "ticket_assigned"); + } + + /** + * 场景3:有新回复 → 技术人员回复则通知客户,客户回复则通知技术人员 + */ + @Async + public void sendReplyNotifyAsync(AppTicket ticket, AppTicketReply reply) { + boolean isStaff = Integer.valueOf(1).equals(reply.getIsStaff()); + String now = now(); + String who = isStaff ? "技术人员" : "用户"; + String notifyRole = isStaff ? "您的工单有新回复" : "工单有用户新回复"; + String title = String.format("💬 %s(%s)", notifyRole, now); + + String wecomContent = String.format( + "## %s\n" + + "> **工单号:** %s\n" + + "> **标题:** %s\n" + + "> **回复人:** %s(%s)\n" + + "> **回复内容:** %s", + title, + nullSafe(ticket.getTicketNo()), + nullSafe(ticket.getTitle()), + nullSafe(reply.getUserName()), who, + truncate(reply.getContent(), 200) + ); + + List feishuLines = Arrays.asList( + new String[]{"工单号:" + nullSafe(ticket.getTicketNo())}, + new String[]{"标 题:" + nullSafe(ticket.getTitle())}, + new String[]{"回复人:" + nullSafe(reply.getUserName()) + "(" + who + ")"}, + new String[]{"回复内容:" + truncate(reply.getContent(), 200)} + ); + + doSend(title, wecomContent, feishuLines, "ticket_reply"); + } + + /** + * 场景4:状态变更(已解决/已关闭)→ 通知提交人 + */ + @Async + public void sendStatusChangedAsync(AppTicket ticket, String status) { + String now = now(); + String emoji = "resolved".equals(status) ? "✅" : "🔒"; + String statusLabel = "resolved".equals(status) ? "已解决" : "已关闭"; + String title = String.format("%s 工单%s(%s)", emoji, statusLabel, now); + + String wecomContent = String.format( + "## %s\n" + + "> **工单号:** %s\n" + + "> **标题:** %s\n" + + "> **提交人:** %s\n" + + "> **处理人:** %s\n" + + "> **状态:** %s", + title, + nullSafe(ticket.getTicketNo()), + nullSafe(ticket.getTitle()), + nullSafe(ticket.getSubmitUserName()), + nullSafe(ticket.getAssigneeName()), + "resolved".equals(status) ? "info" : "comment", + statusLabel + ); + + List feishuLines = Arrays.asList( + new String[]{"工单号:" + nullSafe(ticket.getTicketNo())}, + new String[]{"标 题:" + nullSafe(ticket.getTitle())}, + new String[]{"提交人:" + nullSafe(ticket.getSubmitUserName())}, + new String[]{"处理人:" + nullSafe(ticket.getAssigneeName())}, + new String[]{"状 态:" + statusLabel} + ); + + doSend(title, wecomContent, feishuLines, "ticket_status"); + } + + /** + * 统一发送入口:企业微信 + 飞书 + */ + private void doSend(String title, String wecomContent, List feishuLines, String scene) { + if (StrUtil.isNotBlank(wecomWebhook)) { + try { + sendWecom(wecomContent); + } catch (Exception e) { + log.warn("[工单通知][{}] 企业微信发送失败: {}", scene, e.getMessage()); + } + } + if (StrUtil.isNotBlank(feishuWebhook)) { + try { + sendFeishu(title, feishuLines); + } catch (Exception e) { + log.warn("[工单通知][{}] 飞书发送失败: {}", scene, e.getMessage()); + } + } + } + + /** + * 企业微信群机器人 - markdown 消息 + * 文档:https://developer.work.weixin.qq.com/document/path/91770 + */ + private void sendWecom(String content) { + Map textMap = new HashMap<>(); + textMap.put("content", content); + Map payload = new HashMap<>(); + payload.put("msgtype", "markdown"); + payload.put("markdown", textMap); + String resp = HttpUtil.post(wecomWebhook, JSON.toJSONString(payload)); + log.info("[工单通知] 企业微信推送结果: {}", resp); + } + + /** + * 飞书群机器人 - 富文本(post)消息 + * 文档:https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot + */ + private void sendFeishu(String title, List lines) { + List>> content = new ArrayList<>(); + for (String[] line : lines) { + Map node = new HashMap<>(); + node.put("tag", "text"); + node.put("text", line[0]); + content.add(Collections.singletonList(node)); + } + + Map zhCn = new HashMap<>(); + zhCn.put("title", title); + zhCn.put("content", content); + Map postContent = new HashMap<>(); + postContent.put("zh_cn", zhCn); + Map post = new HashMap<>(); + post.put("content", postContent); + Map payload = new HashMap<>(); + payload.put("msg_type", "post"); + payload.put("content", post); + + String resp = HttpUtil.post(feishuWebhook, JSON.toJSONString(payload)); + log.info("[工单通知] 飞书推送结果: {}", resp); + } + + // ════════════════════════════════════════════════════════════ + // 工具方法 + // ════════════════════════════════════════════════════════════ + + private String now() { + return LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM-dd HH:mm")); + } + + 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; + } + + private String categoryLabel(String category) { + if (category == null) return "其他"; + return switch (category) { + case "bug" -> "Bug反馈"; + case "feature" -> "功能需求"; + case "config" -> "配置问题"; + case "performance" -> "性能问题"; + case "security" -> "安全问题"; + default -> category; + }; + } + + private String priorityLabel(String priority) { + if (priority == null) return "普通"; + return switch (priority) { + case "urgent" -> "🔴 紧急"; + case "high" -> "🟠 高"; + case "normal" -> "🟡 普通"; + case "low" -> "🟢 低"; + default -> priority; + }; + } +} diff --git a/src/main/java/com/gxwebsoft/app/service/impl/AppUserServiceImpl.java b/src/main/java/com/gxwebsoft/app/service/impl/AppUserServiceImpl.java new file mode 100644 index 0000000..9d38689 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/impl/AppUserServiceImpl.java @@ -0,0 +1,112 @@ +package com.gxwebsoft.app.service.impl; + +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.app.mapper.AppUserMapper; +import com.gxwebsoft.app.service.AppUserService; +import com.gxwebsoft.app.entity.AppUser; +import com.gxwebsoft.app.param.AppUserParam; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 应用成员Service实现 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Slf4j +@Service +public class AppUserServiceImpl extends ServiceImpl implements AppUserService { + + /** 注入同 jar 包内的 UserService,用于写入冗余用户信息,不做跨库 JOIN */ + @Resource + private UserService userService; + + @Override + public PageResult pageRel(AppUserParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("sort_number asc, create_time asc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(AppUserParam param) { + List list = baseMapper.selectListRel(param); + PageParam page = new PageParam<>(); + page.setDefaultOrder("sort_number asc, create_time asc"); + return page.sortRecords(list); + } + + @Override + public AppUser getByIdRel(Integer id) { + AppUserParam param = new AppUserParam(); + param.setId(id.longValue()); + return param.getOne(baseMapper.selectListRel(param)); + } + + @Override + public AppUser inviteUser(Long websiteId, Integer userId, String role, Integer inviteBy) { + // 检查是否已经是成员 + if (isMember(websiteId, userId)) { + throw new RuntimeException("该用户已经是应用成员"); + } + + // 查询用户基础信息并冗余写入,避免跨库 JOIN + User sysUser = userService.getByIdIgnoreTenant(userId); + if (sysUser == null) { + throw new RuntimeException("用户不存在,userId=" + userId); + } + + AppUser appUser = new AppUser(); + appUser.setWebsiteId(websiteId); + appUser.setUserId(userId); + appUser.setRole(role != null ? role : "developer"); + appUser.setInviteBy(inviteBy.longValue()); + appUser.setInviteTime(LocalDateTime.now()); + appUser.setStatus(0); + appUser.setSortNumber(0); + // 冗余写入用户基础信息,彻底解除跨库 JOIN 依赖 + appUser.setUsername(sysUser.getUsername()); + appUser.setNickname(sysUser.getNickname()); + appUser.setAvatar(sysUser.getAvatar()); + appUser.setPhone(sysUser.getPhone()); + + save(appUser); + log.info("邀请成员成功,websiteId={}, userId={}, username={}, role={}", websiteId, userId, sysUser.getUsername(), role); + return appUser; + } + + @Override + public boolean updateRole(Long id, String role) { + return update(new LambdaUpdateWrapper() + .eq(AppUser::getId, id) + .set(AppUser::getRole, role)); + } + + @Override + public boolean isMember(Long websiteId, Integer userId) { + long count = count(new LambdaQueryWrapper() + .eq(AppUser::getWebsiteId, websiteId) + .eq(AppUser::getUserId, userId) + .eq(AppUser::getStatus, 0)); + return count > 0; + } + + @Override + public User findUserByPhone(String phone) { + return userService.getByPhone(phone); + } + +} diff --git a/src/main/java/com/gxwebsoft/app/service/impl/AppVersionServiceImpl.java b/src/main/java/com/gxwebsoft/app/service/impl/AppVersionServiceImpl.java new file mode 100644 index 0000000..b45446f --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/impl/AppVersionServiceImpl.java @@ -0,0 +1,112 @@ +package com.gxwebsoft.app.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.app.mapper.AppVersionMapper; +import com.gxwebsoft.app.service.AppVersionService; +import com.gxwebsoft.app.entity.AppVersion; +import com.gxwebsoft.app.param.AppVersionParam; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 应用版本发布记录Service实现 + * + * @author 科技小王子 + * @since 2026-03-28 21:29:44 + */ +@Slf4j +@Service +public class AppVersionServiceImpl extends ServiceImpl implements AppVersionService { + + @Override + public PageResult pageRel(AppVersionParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("create_time desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(AppVersionParam param) { + List list = baseMapper.selectListRel(param); + PageParam page = new PageParam<>(); + page.setDefaultOrder("create_time desc"); + return page.sortRecords(list); + } + + @Override + public AppVersion getByIdRel(Integer id) { + AppVersionParam param = new AppVersionParam(); + param.setId(id.longValue()); + return param.getOne(baseMapper.selectListRel(param)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean publish(Long id, Integer publishBy) { + AppVersion version = getById(id); + if (version == null) { + throw new RuntimeException("版本不存在"); + } + Long websiteId = version.getWebsiteId(); + + // 1. 将该应用下所有版本的 isCurrent 设为 false + update(new LambdaUpdateWrapper() + .eq(AppVersion::getWebsiteId, websiteId) + .set(AppVersion::getIsCurrent, false)); + + // 2. 将当前版本设为已发布+当前版本 + version.setStatus(1); + version.setIsCurrent(true); + version.setPublishBy(publishBy.longValue()); + version.setPublishTime(LocalDateTime.now()); + + boolean result = updateById(version); + log.info("发布版本成功,id={}, versionNo={}", id, version.getVersionNo()); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean rollback(Long id, Integer publishBy) { + AppVersion targetVersion = getById(id); + if (targetVersion == null) { + throw new RuntimeException("目标版本不存在"); + } + Long websiteId = targetVersion.getWebsiteId(); + + // 1. 将当前版本标记为已回滚 + update(new LambdaUpdateWrapper() + .eq(AppVersion::getWebsiteId, websiteId) + .eq(AppVersion::getIsCurrent, true) + .set(AppVersion::getStatus, 2) // 2=已回滚 + .set(AppVersion::getIsCurrent, false)); + + // 2. 将目标版本设为当前版本 + targetVersion.setStatus(1); + targetVersion.setIsCurrent(true); + targetVersion.setPublishBy(publishBy.longValue()); + targetVersion.setPublishTime(LocalDateTime.now()); + + boolean result = updateById(targetVersion); + log.info("回滚版本成功,id={}, versionNo={}", id, targetVersion.getVersionNo()); + return result; + } + + @Override + public AppVersion getCurrentVersion(Long websiteId) { + return getOne(new LambdaQueryWrapper() + .eq(AppVersion::getWebsiteId, websiteId) + .eq(AppVersion::getIsCurrent, true) + .last("LIMIT 1")); + } + +} diff --git a/src/main/java/com/gxwebsoft/app/sql/app_resource.sql b/src/main/java/com/gxwebsoft/app/sql/app_resource.sql new file mode 100644 index 0000000..7f18c0b --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/sql/app_resource.sql @@ -0,0 +1,54 @@ +-- ---------------------------- +-- 开发者资源管理表 +-- 统一存放服务器/数据库/云存储/域名/SSL证书等开发基础设施资源 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `app_resource` ( + `resource_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '资源ID', + `resource_type` VARCHAR(20) NOT NULL COMMENT '资源类型: server/database/storage/domain/ssl', + `name` VARCHAR(100) NOT NULL COMMENT '资源名称', + `provider` VARCHAR(30) DEFAULT NULL COMMENT '服务商: tencent/aliyun/huawei/other', + + -- 服务器字段 + `ip` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址(服务器用)', + + -- 数据库字段 + `db_type` VARCHAR(30) DEFAULT NULL COMMENT '数据库类型: MySQL/PostgreSQL/Redis/MongoDB', + `host` VARCHAR(200) DEFAULT NULL COMMENT '连接主机地址(数据库用)', + `port` INT DEFAULT NULL COMMENT '连接端口(数据库用)', + + -- 云存储字段 + `region` VARCHAR(50) DEFAULT NULL COMMENT '地区/Region(云存储用)', + `acl` VARCHAR(30) DEFAULT 'private' COMMENT '访问权限: public-read/private(云存储用)', + `used_bytes` BIGINT DEFAULT 0 COMMENT '已用空间(字节,云存储用)', + + -- 域名字段 + `domain` VARCHAR(200) DEFAULT NULL COMMENT '域名(域名/SSL用)', + `registrar` VARCHAR(100) DEFAULT NULL COMMENT '注册商(域名用)', + `icp` TINYINT(1) DEFAULT 0 COMMENT '是否已备案: 0否 1是(域名用)', + `icp_no` VARCHAR(100) DEFAULT NULL COMMENT 'ICP备案号(域名用)', + `ssl_bound` TINYINT(1) DEFAULT 0 COMMENT '是否已绑定SSL(域名冗余标记)', + + -- SSL证书字段 + `cert_type` VARCHAR(10) DEFAULT NULL COMMENT '证书类型: DV/OV/EV(SSL用)', + `issuer` VARCHAR(100) DEFAULT NULL COMMENT '颁发机构(SSL用)', + + -- 通用字段 + `status` VARCHAR(20) NOT NULL DEFAULT 'running' COMMENT '状态: running/stopped/expired/pending', + `website_id` BIGINT DEFAULT NULL COMMENT '关联应用ID(可选)', + `expire_at` DATE DEFAULT NULL COMMENT '到期时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `user_id` INT NOT NULL COMMENT '所属用户ID', + `tenant_id` INT DEFAULT NULL COMMENT '租户ID', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除: 0否 1是', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + + PRIMARY KEY (`resource_id`), + KEY `idx_resource_type` (`resource_type`), + KEY `idx_user_id` (`user_id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_website_id` (`website_id`), + KEY `idx_status` (`status`), + KEY `idx_expire_at` (`expire_at`), + KEY `idx_deleted` (`deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发者资源管理(服务器/数据库/云存储/域名/SSL)'; diff --git a/src/main/java/com/gxwebsoft/cms/controller/CmsContactLeadController.java b/src/main/java/com/gxwebsoft/cms/controller/CmsContactLeadController.java new file mode 100644 index 0000000..2b7d6d9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/cms/controller/CmsContactLeadController.java @@ -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> page(CmsContactLeadParam param) { + return success(cmsContactLeadService.pageRel(param)); + } + + @Operation(summary = "查询全部线索") + @PreAuthorize("hasAuthority('cms:contactLead:list')") + @GetMapping() + public ApiResult> list(CmsContactLeadParam param) { + return success(cmsContactLeadService.listRel(param)); + } + + @Operation(summary = "根据ID查询线索") + @PreAuthorize("hasAuthority('cms:contactLead:list')") + @GetMapping("/{id}") + public ApiResult 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 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 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; + } +} diff --git a/src/main/java/com/gxwebsoft/cms/controller/CmsWebsiteController.java b/src/main/java/com/gxwebsoft/cms/controller/CmsWebsiteController.java index 8c6c045..ddf42de 100644 --- a/src/main/java/com/gxwebsoft/cms/controller/CmsWebsiteController.java +++ b/src/main/java/com/gxwebsoft/cms/controller/CmsWebsiteController.java @@ -96,6 +96,16 @@ public class CmsWebsiteController extends BaseController { @Operation(summary = "添加网站信息记录表") @PostMapping() public ApiResult save(@RequestBody CmsWebsite cmsWebsite) { + // 前端若指定了 websiteCode,先做唯一性校验 + if (StrUtil.isNotBlank(cmsWebsite.getWebsiteCode())) { + long cnt = cmsWebsiteService.count( + new LambdaQueryWrapper() + .eq(CmsWebsite::getWebsiteCode, cmsWebsite.getWebsiteCode()) + ); + if (cnt > 0) { + return fail("应用标识 [" + cmsWebsite.getWebsiteCode() + "] 已存在,请更换"); + } + } // 记录当前登录用户id User loginUser = getLoginUser(); if (loginUser != null) { @@ -109,6 +119,20 @@ public class CmsWebsiteController extends BaseController { @Operation(summary = "修改网站信息记录表") @PutMapping() public ApiResult update(@RequestBody CmsWebsite cmsWebsite) { + // websiteCode 全局唯一,有值时校验是否与其他记录冲突(排除自身) + if (StrUtil.isNotBlank(cmsWebsite.getWebsiteCode())) { + long cnt = cmsWebsiteService.count( + new LambdaQueryWrapper() + .eq(CmsWebsite::getWebsiteCode, cmsWebsite.getWebsiteCode()) + .ne(CmsWebsite::getWebsiteId, cmsWebsite.getWebsiteId()) + ); + if (cnt > 0) { + return fail("应用标识 [" + cmsWebsite.getWebsiteCode() + "] 已存在,请更换"); + } + } else { + // 不允许通过此接口清空或修改 websiteCode(设为 null 则 MP 不更新该字段) + cmsWebsite.setWebsiteCode(null); + } if (cmsWebsiteService.updateById(cmsWebsite)) { return success("修改成功"); } @@ -538,4 +562,85 @@ public class CmsWebsiteController extends BaseController { return success("清除成功"); } + // ─── 发布管理 ──────────────────────────────────────────────────────── + + @Operation(summary = "提交上架审核") + @PostMapping("/submitReview") + public ApiResult submitReview(@RequestBody Map body) { + Integer websiteId = body.get("websiteId") instanceof Number ? ((Number) body.get("websiteId")).intValue() : null; + if (websiteId == null) return fail("websiteId 不能为空"); + String priceType = (String) body.get("priceType"); + Integer price = body.get("price") instanceof Number ? ((Number) body.get("price")).intValue() : 0; + String subscriptionPeriod = (String) body.get("subscriptionPeriod"); + String appDescription = (String) body.get("appDescription"); + String detailDescription = (String) body.get("detailDescription"); + String screenshots = (String) body.get("screenshots"); + Integer userId = getLoginUserId(); + try { + cmsWebsiteService.submitReview(websiteId, priceType, price, subscriptionPeriod, + appDescription, detailDescription, screenshots, userId); + return success("上架申请提交成功,等待审核"); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + @Operation(summary = "撤回审核申请") + @PostMapping("/withdrawReview/{websiteId}") + public ApiResult withdrawReview(@PathVariable Integer websiteId) { + try { + cmsWebsiteService.withdrawReview(websiteId, getLoginUserId()); + return success("已撤回审核申请"); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + @Operation(summary = "下架应用") + @PostMapping("/unpublish/{websiteId}") + public ApiResult unpublish(@PathVariable Integer websiteId) { + try { + cmsWebsiteService.unpublish(websiteId, getLoginUserId()); + return success("已下架"); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + @PreAuthorize("hasAuthority('cms:website:update')") + @Operation(summary = "管理员审核通过") + @PostMapping("/approveReview/{websiteId}") + public ApiResult approveReview(@PathVariable Integer websiteId) { + try { + cmsWebsiteService.approveReview(websiteId, getLoginUserId()); + return success("审核通过,应用已上架"); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + @PreAuthorize("hasAuthority('cms:website:update')") + @Operation(summary = "管理员审核拒绝") + @PostMapping("/rejectReview") + public ApiResult rejectReview(@RequestBody Map body) { + Integer websiteId = body.get("websiteId") instanceof Number ? ((Number) body.get("websiteId")).intValue() : null; + String rejectReason = (String) body.get("rejectReason"); + if (websiteId == null) return fail("websiteId 不能为空"); + if (rejectReason == null || rejectReason.isBlank()) return fail("请填写拒绝原因"); + try { + cmsWebsiteService.rejectReview(websiteId, rejectReason, getLoginUserId()); + return success("已拒绝该应用上架申请"); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + @PreAuthorize("hasAuthority('cms:website:update')") + @Operation(summary = "获取审核列表(管理员)") + @GetMapping("/pageReviews") + public ApiResult> pageReviews(CmsWebsiteParam param) { + return success(cmsWebsiteService.pageReviews(param)); + } + } + diff --git a/src/main/java/com/gxwebsoft/cms/entity/CmsContactLead.java b/src/main/java/com/gxwebsoft/cms/entity/CmsContactLead.java new file mode 100644 index 0000000..7e6f604 --- /dev/null +++ b/src/main/java/com/gxwebsoft/cms/entity/CmsContactLead.java @@ -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 -> ""; + }; + } +} diff --git a/src/main/java/com/gxwebsoft/cms/entity/CmsWebsite.java b/src/main/java/com/gxwebsoft/cms/entity/CmsWebsite.java index 46bec1c..7f253b4 100644 --- a/src/main/java/com/gxwebsoft/cms/entity/CmsWebsite.java +++ b/src/main/java/com/gxwebsoft/cms/entity/CmsWebsite.java @@ -66,7 +66,7 @@ public class CmsWebsite implements Serializable { @Schema(description = "网站截图") private String files; - @Schema(description = "网站类型 10企业官网 20微信小程序 30APP 40其他") + @Schema(description = "应用类型 10=web(Web应用) 20=miniprogram(小程序) 30=mobile(移动App) 40=api(API服务) 50=internal(内部工具)") private Integer type; @Schema(description = "网站关键词") @@ -321,6 +321,50 @@ public class CmsWebsite implements Serializable { @TableField(exist = false) private CmsWebsiteSetting setting; + // ─── 发布管理字段 ────────────────────────────────────────────────── + + @Schema(description = "发布状态: developing开发中 pending_review待审核 published已上架 rejected审核未通过 deprecated已下架") + private String publishStatus; + + @Schema(description = "定价模式: free免费 one_time一次性 subscription订阅") + private String priceType; + + @Schema(description = "订阅周期: month按月 year按年") + private String subscriptionPeriod; + + @Schema(description = "应用简介(市场展示用)") + private String appDescription; + + @Schema(description = "详细说明(富文本)") + private String detailDescription; + + @Schema(description = "应用截图(JSON数组字符串)") + private String screenshots; + + @Schema(description = "安装/使用次数") + private Integer installCount; + + @Schema(description = "评分(1-5)") + private java.math.BigDecimal rating; + + @Schema(description = "审核拒绝原因") + private String rejectReason; + + @Schema(description = "提交审核时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private java.util.Date publishApplyTime; + + @Schema(description = "正式发布上架时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private java.util.Date publishTime; + + @Schema(description = "审核人用户ID") + private Integer reviewerId; + + @Schema(description = "审核操作时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private java.util.Date reviewTime; + public String getPhone(){ return DesensitizedUtil.mobilePhone(this.phone); } diff --git a/src/main/java/com/gxwebsoft/cms/mapper/CmsContactLeadMapper.java b/src/main/java/com/gxwebsoft/cms/mapper/CmsContactLeadMapper.java new file mode 100644 index 0000000..d8cc641 --- /dev/null +++ b/src/main/java/com/gxwebsoft/cms/mapper/CmsContactLeadMapper.java @@ -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 { + + /** + * 分页查询 + * + * @param page 分页对象 + * @param param 查询参数 + * @return List + */ + List selectPageRel(@Param("page") IPage page, + @Param("param") CmsContactLeadParam param); + + /** + * 查询全部 + * + * @param param 查询参数 + * @return List + */ + List selectListRel(@Param("param") CmsContactLeadParam param); +} diff --git a/src/main/java/com/gxwebsoft/cms/mapper/xml/CmsContactLeadMapper.xml b/src/main/java/com/gxwebsoft/cms/mapper/xml/CmsContactLeadMapper.xml new file mode 100644 index 0000000..d1fabdf --- /dev/null +++ b/src/main/java/com/gxwebsoft/cms/mapper/xml/CmsContactLeadMapper.xml @@ -0,0 +1,66 @@ + + + + + + + SELECT a.* + FROM cms_contact_lead a + + + AND a.lead_id = #{param.leadId} + + + AND a.name LIKE CONCAT('%', #{param.name}, '%') + + + AND a.phone LIKE CONCAT('%', #{param.phone}, '%') + + + AND a.company LIKE CONCAT('%', #{param.company}, '%') + + + AND a.delivery = #{param.delivery} + + + AND a.source = #{param.source} + + + AND a.status = #{param.status} + + + 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}, '%') + ) + + + AND a.deleted = #{param.deleted} + + + AND a.deleted = 0 + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/cms/mapper/xml/CmsWebsiteMapper.xml b/src/main/java/com/gxwebsoft/cms/mapper/xml/CmsWebsiteMapper.xml index 577c1c6..fb17bb1 100644 --- a/src/main/java/com/gxwebsoft/cms/mapper/xml/CmsWebsiteMapper.xml +++ b/src/main/java/com/gxwebsoft/cms/mapper/xml/CmsWebsiteMapper.xml @@ -430,6 +430,15 @@ #{item} + + AND ( + a.user_id = #{param.memberUserId} + OR a.website_id IN ( + SELECT au.website_id FROM app_user au + WHERE au.user_id = #{param.memberUserId} + ) + ) + AND (a.website_name LIKE CONCAT('%', #{param.keywords}, '%') OR a.website_code LIKE CONCAT('%', #{param.keywords}, '%') diff --git a/src/main/java/com/gxwebsoft/cms/param/CmsContactLeadParam.java b/src/main/java/com/gxwebsoft/cms/param/CmsContactLeadParam.java new file mode 100644 index 0000000..4855dc9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/cms/param/CmsContactLeadParam.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/cms/param/CmsWebsiteParam.java b/src/main/java/com/gxwebsoft/cms/param/CmsWebsiteParam.java index cafb74d..7c7be19 100644 --- a/src/main/java/com/gxwebsoft/cms/param/CmsWebsiteParam.java +++ b/src/main/java/com/gxwebsoft/cms/param/CmsWebsiteParam.java @@ -214,6 +214,9 @@ public class CmsWebsiteParam extends BaseParam { @Schema(description = "按WebsiteIds集搜索") private Set websiteIds; + @Schema(description = "协作成员userId(查该用户作为成员参与的应用,联表 app_user)") + private Integer memberUserId; + @Schema(description = "当前登录用户ID") @QueryField(type = QueryType.EQ) private Integer loginUserId; @@ -222,4 +225,7 @@ public class CmsWebsiteParam extends BaseParam { @QueryField(type = QueryType.EQ) private String adminPhone; + @Schema(description = "发布状态筛选: developing/pending_review/published/rejected/deprecated") + private String publishStatus; + } diff --git a/src/main/java/com/gxwebsoft/cms/service/CmsContactLeadService.java b/src/main/java/com/gxwebsoft/cms/service/CmsContactLeadService.java new file mode 100644 index 0000000..e1c5e02 --- /dev/null +++ b/src/main/java/com/gxwebsoft/cms/service/CmsContactLeadService.java @@ -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 { + + /** + * 分页关联查询 + * + * @param param 查询参数 + * @return PageResult + */ + PageResult pageRel(CmsContactLeadParam param); + + /** + * 关联查询全部 + * + * @param param 查询参数 + * @return List + */ + List listRel(CmsContactLeadParam param); + + /** + * 提交联系表单(不需要登录) + * + * @param lead 表单数据 + * @param ip 客户端IP + * @return boolean + */ + boolean submit(CmsContactLead lead, String ip); +} diff --git a/src/main/java/com/gxwebsoft/cms/service/CmsWebsiteService.java b/src/main/java/com/gxwebsoft/cms/service/CmsWebsiteService.java index a78dfa2..db72d5d 100644 --- a/src/main/java/com/gxwebsoft/cms/service/CmsWebsiteService.java +++ b/src/main/java/com/gxwebsoft/cms/service/CmsWebsiteService.java @@ -67,4 +67,26 @@ public interface CmsWebsiteService extends IService { * @param tenantId 租户ID */ void clearSiteInfoCache(Integer tenantId); + + // ─── 发布管理 ────────────────────────────────────────────────── + + /** 提交上架审核 */ + void submitReview(Integer websiteId, String priceType, Integer price, + String subscriptionPeriod, String appDescription, + String detailDescription, String screenshots, Integer userId); + + /** 撤回审核申请(回到开发中) */ + void withdrawReview(Integer websiteId, Integer userId); + + /** 下架应用 */ + void unpublish(Integer websiteId, Integer userId); + + /** 管理员审核通过 */ + void approveReview(Integer websiteId, Integer reviewerId); + + /** 管理员审核拒绝 */ + void rejectReview(Integer websiteId, String rejectReason, Integer reviewerId); + + /** 分页查询审核列表(管理员) */ + PageResult pageReviews(CmsWebsiteParam param); } diff --git a/src/main/java/com/gxwebsoft/cms/service/impl/CmsContactLeadServiceImpl.java b/src/main/java/com/gxwebsoft/cms/service/impl/CmsContactLeadServiceImpl.java new file mode 100644 index 0000000..6fe172e --- /dev/null +++ b/src/main/java/com/gxwebsoft/cms/service/impl/CmsContactLeadServiceImpl.java @@ -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 + implements CmsContactLeadService { + + /** 企业微信群机器人 Webhook,留空则不发送 */ + @Value("${notify.wecom-webhook:}") + private String wecomWebhook; + + /** 飞书群机器人 Webhook,留空则不发送 */ + @Value("${notify.feishu-webhook:}") + private String feishuWebhook; + + // -------------------------------------------------------- + // 分页 / 列表 + // -------------------------------------------------------- + + @Override + public PageResult pageRel(CmsContactLeadParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("create_time desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List 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" + + "> **手机:** %s\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 textMap = new HashMap<>(); + textMap.put("content", content); + + Map 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>> 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 zhCn = new HashMap<>(); + zhCn.put("title", title); + zhCn.put("content", content); + + Map postContent = new HashMap<>(); + postContent.put("zh_cn", zhCn); + + Map post = new HashMap<>(); + post.put("content", postContent); + + Map 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> line(String text) { + Map node = new HashMap<>(); + node.put("tag", "text"); + node.put("text", text); + return List.of(node); + } +} diff --git a/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImpl.java b/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImpl.java index 348d3e5..c0aeb0e 100644 --- a/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImpl.java +++ b/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImpl.java @@ -145,15 +145,45 @@ public class CmsWebsiteServiceImpl extends ServiceImpl 0) { // TODO 国际化 @@ -387,9 +417,113 @@ public class CmsWebsiteServiceImpl extends ServiceImpl pageReviews(CmsWebsiteParam param) { + com.baomidou.mybatisplus.extension.plugins.pagination.Page page = + new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(param.getPage() != null ? param.getPage() : 1, param.getLimit() != null && param.getLimit() > 0 ? param.getLimit() : 20); + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper = + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(CmsWebsite::getDeleted, 0) + .isNotNull(CmsWebsite::getPublishStatus) + .ne(CmsWebsite::getPublishStatus, "developing") + .eq(cn.hutool.core.util.ObjectUtil.isNotEmpty(param.getPublishStatus()), + CmsWebsite::getPublishStatus, param.getPublishStatus()) + .and(cn.hutool.core.util.ObjectUtil.isNotEmpty(param.getKeywords()), q -> + q.like(CmsWebsite::getWebsiteName, param.getKeywords()) + .or().like(CmsWebsite::getWebsiteCode, param.getKeywords())) + .last("ORDER BY FIELD(publish_status,'pending_review','rejected','published','deprecated'), publish_apply_time DESC"); + baseMapper.selectPage(page, wrapper); + return new com.gxwebsoft.common.core.web.PageResult<>(page.getRecords(), page.getTotal()); + } + + private CmsWebsite getWebsiteFromDatabase(Integer tenantId) { return getByTenantId(tenantId); } @@ -432,4 +566,22 @@ public class CmsWebsiteServiceImpl extends ServiceImpl bottomNavs = cmsNavigationService.listRel(navigationParam); website.setBottomNavs(bottomNavs); } + + /** + * 生成全局唯一的 websiteCode。 + *

以 baseCode 为基础,若已存在则追加 -2、-3 … 直到找到空闲值。

+ * + * @param baseCode 期望的 code 前缀,如 "site-10398" + * @return 全局唯一的 websiteCode + */ + private String generateUniqueCode(String baseCode) { + String candidate = baseCode; + int suffix = 2; + while (count(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(CmsWebsite::getWebsiteCode, candidate)) > 0) { + candidate = baseCode + "-" + suffix; + suffix++; + } + return candidate; + } } diff --git a/src/main/java/com/gxwebsoft/cms/sql/cms_contact_lead.sql b/src/main/java/com/gxwebsoft/cms/sql/cms_contact_lead.sql new file mode 100644 index 0000000..45d6154 --- /dev/null +++ b/src/main/java/com/gxwebsoft/cms/sql/cms_contact_lead.sql @@ -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='联系表单/销售线索'; diff --git a/src/main/java/com/gxwebsoft/cms/sql/cms_website_publish.sql b/src/main/java/com/gxwebsoft/cms/sql/cms_website_publish.sql new file mode 100644 index 0000000..a70f593 --- /dev/null +++ b/src/main/java/com/gxwebsoft/cms/sql/cms_website_publish.sql @@ -0,0 +1,17 @@ +-- 为 cms_website 表新增发布管理相关字段 +-- 执行前请确认已连接正确的数据库 + +ALTER TABLE `cms_website` + ADD COLUMN `publish_status` VARCHAR(20) NULL DEFAULT 'developing' COMMENT '发布状态: developing开发中 pending_review待审核 published已上架 rejected审核未通过 deprecated已下架' AFTER `market`, + ADD COLUMN `price_type` VARCHAR(20) NULL COMMENT '定价模式: free免费 one_time一次性 subscription订阅' AFTER `publish_status`, + ADD COLUMN `subscription_period` VARCHAR(10) NULL COMMENT '订阅周期: month按月 year按年' AFTER `price_type`, + ADD COLUMN `app_description` VARCHAR(500) NULL COMMENT '应用简介(市场展示用)' AFTER `subscription_period`, + ADD COLUMN `detail_description` TEXT NULL COMMENT '详细说明(富文本)' AFTER `app_description`, + ADD COLUMN `screenshots` TEXT NULL COMMENT '应用截图(JSON数组字符串)' AFTER `detail_description`, + ADD COLUMN `install_count` INT NULL DEFAULT 0 COMMENT '安装/使用次数' AFTER `screenshots`, + ADD COLUMN `rating` DECIMAL(3,1) NULL DEFAULT 0.0 COMMENT '评分(1-5)' AFTER `install_count`, + ADD COLUMN `reject_reason` VARCHAR(500) NULL COMMENT '审核拒绝原因' AFTER `rating`, + ADD COLUMN `publish_apply_time` DATETIME NULL COMMENT '提交审核时间' AFTER `reject_reason`, + ADD COLUMN `publish_time` DATETIME NULL COMMENT '正式发布上架时间' AFTER `publish_apply_time`, + ADD COLUMN `reviewer_id` INT NULL COMMENT '审核人用户ID' AFTER `publish_time`, + ADD COLUMN `review_time` DATETIME NULL COMMENT '审核操作时间' AFTER `reviewer_id`; diff --git a/src/main/java/com/gxwebsoft/common/core/config/JsonArrayToStringDeserializer.java b/src/main/java/com/gxwebsoft/common/core/config/JsonArrayToStringDeserializer.java new file mode 100644 index 0000000..dbb4989 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/config/JsonArrayToStringDeserializer.java @@ -0,0 +1,57 @@ +package com.gxwebsoft.common.core.config; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.List; + +/** + * JSON 数组 ↔ String 反序列化器 + *

+ * 数据库字段存储的是 JSON 字符串(如 ["url1","url2"]), + * 前端传来的是 JSON 数组;此反序列化器把两种形式都正确处理为 String 存库。 + * + * @author WebSoft + * @since 2026-03-30 + */ +@Slf4j +public class JsonArrayToStringDeserializer extends JsonDeserializer { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + // 如果前端传的已经是字符串,直接返回 + if (p.currentToken() == JsonToken.VALUE_STRING) { + String text = p.getText(); + if (text == null || text.trim().isEmpty()) { + return null; + } + return text; + } + // 如果前端传的是数组,序列化成 JSON 字符串 + if (p.currentToken() == JsonToken.START_ARRAY) { + List list = MAPPER.readValue(p, MAPPER.getTypeFactory() + .constructCollectionType(List.class, String.class)); + if (list == null || list.isEmpty()) { + return null; + } + return MAPPER.writeValueAsString(list); + } + // null + if (p.currentToken() == JsonToken.VALUE_NULL) { + return null; + } + return p.getValueAsString(); + } + + @Override + public String getNullValue(DeserializationContext ctxt) { + return null; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/config/JsonStringToArraySerializer.java b/src/main/java/com/gxwebsoft/common/core/config/JsonStringToArraySerializer.java new file mode 100644 index 0000000..b42390d --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/config/JsonStringToArraySerializer.java @@ -0,0 +1,45 @@ +package com.gxwebsoft.common.core.config; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +/** + * JSON 字符串 → 数组 序列化器 + *

+ * 数据库中 attachments 存储为 JSON 字符串,响应给前端时自动转成数组。 + * + * @author WebSoft + * @since 2026-03-30 + */ +@Slf4j +public class JsonStringToArraySerializer extends JsonSerializer { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (value == null || value.trim().isEmpty()) { + gen.writeStartArray(); + gen.writeEndArray(); + return; + } + try { + List list = MAPPER.readValue(value, + MAPPER.getTypeFactory().constructCollectionType(List.class, String.class)); + gen.writeObject(list); + } catch (Exception e) { + // 解析失败(非JSON格式)就把原始字符串包成单元素数组 + log.warn("attachments 字段不是合法 JSON 数组,原样包装: {}", value); + gen.writeStartArray(); + gen.writeString(value); + gen.writeEndArray(); + } + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java index 8cd95a0..c9d1463 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java @@ -80,7 +80,8 @@ public class SecurityConfig { "/api/shop/getShopInfo", "/api/shop/shop-order/test", "/api/qr-code/**", - "/api/shop/order-delivery/notify" + "/api/shop/order-delivery/notify", + "/api/cms/cms-contact-lead/submit" ) .permitAll() .anyRequest() diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/UserMapper.java b/src/main/java/com/gxwebsoft/common/system/mapper/UserMapper.java index 41c8be0..a5c4f35 100644 --- a/src/main/java/com/gxwebsoft/common/system/mapper/UserMapper.java +++ b/src/main/java/com/gxwebsoft/common/system/mapper/UserMapper.java @@ -62,6 +62,14 @@ public interface UserMapper extends BaseMapper { @InterceptorIgnore(tenantLine = "true") User selectByIdIgnoreTenant(@Param("userId") Integer userId); + /** + * 根据手机号查询用户(忽略租户隔离,跨库查 gxwebsoft_core.sys_user) + * @param phone 手机号 + * @return User + */ + @InterceptorIgnore(tenantLine = "true") + User selectByPhone(@Param("phone") String phone); + @InterceptorIgnore(tenantLine = "true") List pageAdminByPhone(@Param("param") UserParam param); diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/xml/UserMapper.xml b/src/main/java/com/gxwebsoft/common/system/mapper/xml/UserMapper.xml index df27bef..4a2a32e 100644 --- a/src/main/java/com/gxwebsoft/common/system/mapper/xml/UserMapper.xml +++ b/src/main/java/com/gxwebsoft/common/system/mapper/xml/UserMapper.xml @@ -182,20 +182,13 @@ - + diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java index b321832..6ba5309 100644 --- a/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java @@ -180,7 +180,8 @@ public class UserServiceImpl extends ServiceImpl implements Us @Override public User getByPhone(String phone) { - return query().eq("phone", phone).one(); + // 使用自定义 SQL(@InterceptorIgnore),避免 TenantLineInterceptor 把表定位到当前库的 sys_user + return baseMapper.selectByPhone(phone); } @Override diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index dcba0f0..10fe6e9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,7 +4,7 @@ server: # 多环境配置 spring: profiles: - active: ysb2 + active: dev application: name: server @@ -258,9 +258,12 @@ payment: # 开发环境是否启用环境感知 environment-aware: true - # 生产环境配置 - prod: - # 生产环境回调地址 - notify-url: "https://cms-api.websoft.top/api/shop/shop-order/notify" - # 生产环境是否启用环境感知 - environment-aware: false + +# 通知配置(企业微信/飞书机器人 Webhook) +notify: + # 企业微信群机器人 Webhook,格式:https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY + # 不启用时留空或删除此行 + 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: "" + diff --git a/src/test/java/com/gxwebsoft/generator/AppGenerator.java b/src/test/java/com/gxwebsoft/generator/AppGenerator.java new file mode 100644 index 0000000..5557843 --- /dev/null +++ b/src/test/java/com/gxwebsoft/generator/AppGenerator.java @@ -0,0 +1,384 @@ +package com.gxwebsoft.generator; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.core.toolkit.StringPool; +import com.baomidou.mybatisplus.generator.AutoGenerator; +import com.baomidou.mybatisplus.generator.InjectionConfig; +import com.baomidou.mybatisplus.generator.config.*; +import com.baomidou.mybatisplus.generator.config.po.TableInfo; +import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy; +import com.gxwebsoft.generator.engine.BeetlTemplateEnginePlus; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * CMS模块-代码生成工具 + * + * @author WebSoft + * @since 2021-09-05 00:31:14 + */ +public class AppGenerator { + // 输出位置 + private static final String OUTPUT_LOCATION = System.getProperty("user.dir"); + //private static final String OUTPUT_LOCATION = "D:/codegen"; // 不想生成到项目中可以写磁盘路径 + // JAVA输出目录 + private static final String OUTPUT_DIR = "/src/main/java"; + // Vue文件输出位置 + private static final String OUTPUT_LOCATION_VUE = "/Users/gxwebsoft/VUE/mp-vue"; + // UniApp文件输出目录 + private static final String OUTPUT_LOCATION_UNIAPP = "/Users/gxwebsoft/VUE/template-5"; + // Vue文件输出目录 + private static final String OUTPUT_DIR_VUE = "/src"; + // 作者名称 + private static final String AUTHOR = "科技小王子"; + // 是否在xml中添加二级缓存配置 + private static final boolean ENABLE_CACHE = false; + // 数据库连接配置 + private static final String DB_URL = "jdbc:mysql://47.119.165.234:13308/modules?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8"; + private static final String DB_DRIVER = "com.mysql.cj.jdbc.Driver"; + private static final String DB_USERNAME = "modules"; + private static final String DB_PASSWORD = "P7KsAyDXG8YdLnkA"; + // 包名 + private static final String PACKAGE_NAME = "com.gxwebsoft"; + // 模块名 + private static final String MODULE_NAME = "app"; + // 需要生成的表 + private static final String[] TABLE_NAMES = new String[]{ + "app_user", + "app_version", + "app_event", + "app_credential" + }; + // 需要去除的表前缀 + private static final String[] TABLE_PREFIX = new String[]{ + "tb_" + }; + // 不需要作为查询参数的字段 + private static final String[] PARAM_EXCLUDE_FIELDS = new String[]{ + "tenant_id", + "create_time", + "update_time" + }; + // 查询参数使用String的类型 + private static final String[] PARAM_TO_STRING_TYPE = new String[]{ + "Date", + "LocalDate", + "LocalTime", + "LocalDateTime" + }; + // 查询参数使用EQ的类型 + private static final String[] PARAM_EQ_TYPE = new String[]{ + "Integer", + "Boolean", + "BigDecimal" + }; + // 是否添加权限注解 + private static final boolean AUTH_ANNOTATION = true; + // 是否添加日志注解 + private static final boolean LOG_ANNOTATION = true; + // controller的mapping前缀 + private static final String CONTROLLER_MAPPING_PREFIX = "/api"; + // 模板所在位置 + private static final String TEMPLATES_DIR = "/src/test/java/com/gxwebsoft/generator/templates"; + + public static void main(String[] args) { + // 代码生成器 + AutoGenerator mpg = new AutoGenerator(); + + // 全局配置 + GlobalConfig gc = new GlobalConfig(); + gc.setOutputDir(OUTPUT_LOCATION + OUTPUT_DIR); + gc.setAuthor(AUTHOR); + gc.setOpen(false); + gc.setFileOverride(true); + gc.setEnableCache(ENABLE_CACHE); + gc.setSwagger2(true); + gc.setIdType(IdType.AUTO); + gc.setServiceName("%sService"); + mpg.setGlobalConfig(gc); + + // 数据源配置 + DataSourceConfig dsc = new DataSourceConfig(); + dsc.setUrl(DB_URL); + // dsc.setSchemaName("public"); + dsc.setDriverName(DB_DRIVER); + dsc.setUsername(DB_USERNAME); + dsc.setPassword(DB_PASSWORD); + mpg.setDataSource(dsc); + + // 包配置 + PackageConfig pc = new PackageConfig(); + pc.setModuleName(MODULE_NAME); + pc.setParent(PACKAGE_NAME); + mpg.setPackageInfo(pc); + + // 策略配置 + StrategyConfig strategy = new StrategyConfig(); + strategy.setNaming(NamingStrategy.underline_to_camel); + strategy.setColumnNaming(NamingStrategy.underline_to_camel); + strategy.setInclude(TABLE_NAMES); + strategy.setTablePrefix(TABLE_PREFIX); + strategy.setSuperControllerClass(PACKAGE_NAME + ".common.core.web.BaseController"); + strategy.setEntityLombokModel(true); + strategy.setRestControllerStyle(true); + strategy.setControllerMappingHyphenStyle(true); + strategy.setLogicDeleteFieldName("deleted"); + mpg.setStrategy(strategy); + + // 模板配置 + TemplateConfig templateConfig = new TemplateConfig(); + templateConfig.setController(TEMPLATES_DIR + "/controller.java"); + templateConfig.setEntity(TEMPLATES_DIR + "/entity.java"); + templateConfig.setMapper(TEMPLATES_DIR + "/mapper.java"); + templateConfig.setXml(TEMPLATES_DIR + "/mapper.xml"); + templateConfig.setService(TEMPLATES_DIR + "/service.java"); + templateConfig.setServiceImpl(TEMPLATES_DIR + "/serviceImpl.java"); + mpg.setTemplate(templateConfig); + mpg.setTemplateEngine(new BeetlTemplateEnginePlus()); + + // 自定义模板配置 + InjectionConfig cfg = new InjectionConfig() { + @Override + public void initMap() { + Map map = new HashMap<>(); + map.put("packageName", PACKAGE_NAME); + map.put("paramExcludeFields", PARAM_EXCLUDE_FIELDS); + map.put("paramToStringType", PARAM_TO_STRING_TYPE); + map.put("paramEqType", PARAM_EQ_TYPE); + map.put("authAnnotation", AUTH_ANNOTATION); + map.put("logAnnotation", LOG_ANNOTATION); + map.put("controllerMappingPrefix", CONTROLLER_MAPPING_PREFIX); + // 添加项目类型标识,用于模板中的条件判断 + map.put("isUniApp", false); // Vue 项目 + map.put("isVueAdmin", true); // 后台管理项目 + this.setMap(map); + } + }; + String templatePath = TEMPLATES_DIR + "/param.java.btl"; + List focList = new ArrayList<>(); + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION + OUTPUT_DIR + "/" + + PACKAGE_NAME.replace(".", "/") + + "/" + pc.getModuleName() + "/param/" + + tableInfo.getEntityName() + "Param" + StringPool.DOT_JAVA; + } + }); + /** + * 以下是生成VUE项目代码 + * 生成文件的路径 /api/shop/goods/index.ts + */ + templatePath = TEMPLATES_DIR + "/index.ts.btl"; + + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_VUE + OUTPUT_DIR_VUE + + "/api/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/" + "index.ts"; + } + }); + // UniApp 使用专门的模板 + String uniappTemplatePath = TEMPLATES_DIR + "/index.ts.uniapp.btl"; + focList.add(new FileOutConfig(uniappTemplatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_UNIAPP + OUTPUT_DIR_VUE + + "/api/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/" + "index.ts"; + } + }); + // 生成TS文件 (/api/shop/goods/model/index.ts) + templatePath = TEMPLATES_DIR + "/model.ts.btl"; + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_VUE + OUTPUT_DIR_VUE + + "/api/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/model/" + "index.ts"; + } + }); + // UniApp 使用专门的 model 模板 + String uniappModelTemplatePath = TEMPLATES_DIR + "/model.ts.uniapp.btl"; + focList.add(new FileOutConfig(uniappModelTemplatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_UNIAPP + OUTPUT_DIR_VUE + + "/api/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/model/" + "index.ts"; + } + }); + // 生成Vue文件(/views/shop/goods/index.vue) + templatePath = TEMPLATES_DIR + "/index.vue.btl"; + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_VUE + OUTPUT_DIR_VUE + + "/views/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/" + "index.vue"; + } + }); + + // 生成components文件(/views/shop/goods/components/edit.vue) + templatePath = TEMPLATES_DIR + "/components.edit.vue.btl"; + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_VUE + OUTPUT_DIR_VUE + + "/views/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/components/" + tableInfo.getEntityPath() + "Edit.vue"; + } + }); + + // 生成components文件(/views/shop/goods/components/search.vue) + templatePath = TEMPLATES_DIR + "/components.search.vue.btl"; + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_VUE + OUTPUT_DIR_VUE + + "/views/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/components/" + "search.vue"; + } + }); + + // ========== 移动端页面文件生成 ========== + // 生成移动端列表页面配置文件 (/src/shop/goods/index.config.ts) + templatePath = TEMPLATES_DIR + "/index.config.ts.btl"; + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_UNIAPP + OUTPUT_DIR_VUE + + "/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/" + "index.config.ts"; + } + }); + + // 生成移动端列表页面组件文件 (/src/shop/goods/index.tsx) + templatePath = TEMPLATES_DIR + "/index.tsx.btl"; + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_UNIAPP + OUTPUT_DIR_VUE + + "/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/" + "index.tsx"; + } + }); + + // 生成移动端新增/编辑页面配置文件 (/src/shop/goods/add.config.ts) + templatePath = TEMPLATES_DIR + "/add.config.ts.btl"; + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_UNIAPP + OUTPUT_DIR_VUE + + "/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/" + "add.config.ts"; + } + }); + + // 生成移动端新增/编辑页面组件文件 (/src/shop/goods/add.tsx) + templatePath = TEMPLATES_DIR + "/add.tsx.btl"; + focList.add(new FileOutConfig(templatePath) { + @Override + public String outputFile(TableInfo tableInfo) { + return OUTPUT_LOCATION_UNIAPP + OUTPUT_DIR_VUE + + "/" + pc.getModuleName() + "/" + + tableInfo.getEntityPath() + "/" + "add.tsx"; + } + }); + + cfg.setFileOutConfigList(focList); + mpg.setCfg(cfg); + + mpg.execute(); + + // 自动更新 app.config.ts + updateAppConfig(TABLE_NAMES, MODULE_NAME); + } + + /** + * 自动更新 app.config.ts 文件,添加新生成的页面路径 + */ + private static void updateAppConfig(String[] tableNames, String moduleName) { + String appConfigPath = OUTPUT_LOCATION_UNIAPP + OUTPUT_DIR_VUE + "/app.config.ts"; + + try { + // 读取原文件内容 + String content = new String(Files.readAllBytes(Paths.get(appConfigPath))); + + // 为每个表生成页面路径 + StringBuilder newPages = new StringBuilder(); + for (String tableName : tableNames) { + String entityPath = tableName.replaceAll("_", ""); + // 转换为驼峰命名 + String[] parts = tableName.split("_"); + StringBuilder camelCase = new StringBuilder(parts[0]); + for (int i = 1; i < parts.length; i++) { + camelCase.append(parts[i].substring(0, 1).toUpperCase()).append(parts[i].substring(1)); + } + entityPath = camelCase.toString(); + + newPages.append(" '").append(entityPath).append("/index',\n"); + newPages.append(" '").append(entityPath).append("/add',\n"); + } + + // 查找对应模块的子包配置 + String modulePattern = "\"root\":\\s*\"" + moduleName + "\",\\s*\"pages\":\\s*\\[([^\\]]*)]"; + Pattern pattern = Pattern.compile(modulePattern, Pattern.DOTALL); + Matcher matcher = pattern.matcher(content); + + if (matcher.find()) { + String existingPages = matcher.group(1); + + // 检查页面是否已存在,避免重复添加 + boolean needUpdate = false; + String[] newPageArray = newPages.toString().split("\n"); + for (String newPage : newPageArray) { + if (!newPage.trim().isEmpty() && !existingPages.contains(newPage.trim().replace(" ", "").replace(",", ""))) { + needUpdate = true; + break; + } + } + + if (needUpdate) { + // 备份原文件 + String backupPath = appConfigPath + ".backup." + System.currentTimeMillis(); + Files.copy(Paths.get(appConfigPath), Paths.get(backupPath)); + System.out.println("已备份原文件到: " + backupPath); + + // 在现有页面列表末尾添加新页面 + String updatedPages = existingPages.trim(); + if (!updatedPages.endsWith(",")) { + updatedPages += ","; + } + updatedPages += "\n" + newPages.toString().trim(); + + // 替换内容 + String updatedContent = content.replace(matcher.group(1), updatedPages); + + // 写入更新后的内容 + Files.write(Paths.get(appConfigPath), updatedContent.getBytes()); + + System.out.println("✅ 已自动更新 app.config.ts,添加了以下页面路径:"); + System.out.println(newPages.toString()); + } else { + System.out.println("ℹ️ app.config.ts 中已包含所有页面路径,无需更新"); + } + } else { + System.out.println("⚠️ 未找到 " + moduleName + " 模块的子包配置,请手动添加页面路径"); + } + + } catch (Exception e) { + System.err.println("❌ 更新 app.config.ts 失败: " + e.getMessage()); + e.printStackTrace(); + } + } + +}