diff --git a/sql/mysql/ruoyi-vue-pro.sql b/sql/mysql/ruoyi-vue-pro.sql
index 3e4ca9ef3..79a6e8bd4 100644
--- a/sql/mysql/ruoyi-vue-pro.sql
+++ b/sql/mysql/ruoyi-vue-pro.sql
@@ -2341,6 +2341,13 @@ INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_i
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (5044, '删除模版消息', 'mp:message-template:delete', 3, 2, 5042, '', '', '', '', 0, b'1', b'1', b'1', '1', '2025-11-26 17:00:31', '1', '2025-11-26 18:45:05', b'0');
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (5045, '同步公众号模板', 'mp:message-template:sync', 3, 3, 5042, '', '', '', '', 0, b'1', b'1', b'1', '1', '2025-11-26 17:00:55', '1', '2025-11-26 17:00:55', b'0');
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (5046, '给粉丝发送模版消息', 'mp:message-template:send', 3, 4, 5042, '', '', '', '', 0, b'1', b'1', b'1', '1', '2025-11-26 17:01:11', '1', '2025-11-26 17:01:11', b'0');
+INSERT INTO `system_menu` (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES (5047, '短链管理', '', 1, 6, 1, '', 'ep:loading', '', '', 0, true, true, true, '1', '2025-11-26 17:01:11', '1', '2025-12-04 17:41:34', false);
+INSERT INTO `system_menu` (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES (5048, '新增短链', 'system:short-link:create', 3, 1, 5053, '', '', '', '', 0, true, true, true, '1', '2025-11-26 17:01:11', '1', '2025-12-04 17:35:42', false);
+INSERT INTO `system_menu` (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES (5049, '修改短链', 'system:short-link:update', 3, 2, 5053, '', '', '', '', 0, true, true, true, '1', '2025-11-26 17:01:11', '1', '2025-12-04 17:35:42', false);
+INSERT INTO `system_menu` (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES (5050, '删除短链', 'system:short-link:delete', 3, 3, 5053, '', '', '', '', 0, true, true, true, '1', '2025-11-26 17:01:11', '1', '2025-12-04 17:35:42', false);
+INSERT INTO `system_menu` (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES (5051, '查询短链', 'system:short-link:query', 3, 4, 5053, '', '', '', '', 0, true, true, true, '1', '2025-11-26 17:01:11', '1', '2025-12-04 17:35:42', false);
+INSERT INTO `system_menu` (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES (5052, '统计', '', 2, 2, 5047, 'shortlink/statistics', 'ep:picture-rounded', 'system/shortlink/statistics', 'SystemShortlinkStatistics', 0, true, true, true, '1', '2025-12-04 17:30:04', '1', '2025-12-04 18:11:26', false);
+INSERT INTO `system_menu` (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) VALUES (5053, '短链', '', 2, 1, 5047, 'shortlink/index', 'ep:magic-stick', 'system/shortlink/index', 'SystemShortlink', 0, true, true, true, '1', '2025-12-04 17:31:48', '1', '2025-12-04 17:39:14', false);
COMMIT;
-- ----------------------------
@@ -4014,6 +4021,80 @@ INSERT INTO `system_users` (`id`, `username`, `password`, `nickname`, `remark`,
INSERT INTO `system_users` (`id`, `username`, `password`, `nickname`, `remark`, `dept_id`, `post_ids`, `email`, `mobile`, `sex`, `avatar`, `status`, `login_ip`, `login_date`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`) VALUES (144, 'aoteman001', '$2a$04$omQOmhz8OyUFBKw77nr8KOtMp6xdvoQ1gWStjk9r8.OYT3Bv6oEYe', 'aoteman001', NULL, 116, NULL, '', '', 0, '', 1, '0:0:0:0:0:0:0:1', '2025-12-01 17:05:27', '1', '2025-12-01 17:05:27', '1', '2025-12-15 15:55:54', b'0', 1);
COMMIT;
+-- ----------------------------
+-- Table structure for system_short_link
+-- ----------------------------
+DROP TABLE IF EXISTS `system_short_link`;
+CREATE TABLE `system_short_link` (
+ `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号,数据库自增',
+ `short_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '短码',
+ `original_url` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始URL',
+ `title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '链接标题',
+ `description` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '链接描述',
+ `domain` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '域名配置',
+ `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态(1-启用,0-禁用)',
+ `expire_time` datetime DEFAULT NULL COMMENT '过期时间',
+ `click_count` bigint NOT NULL DEFAULT '0' COMMENT '点击次数',
+ `last_click_time` datetime DEFAULT NULL COMMENT '最后点击时间',
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
+ `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
+ `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
+ `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+ `forever` bit(1) NOT NULL COMMENT '是否永久有效',
+ `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_short_code` (`short_code`),
+ KEY `idx_status` (`status`),
+ KEY `idx_create_time` (`create_time`),
+ KEY `idx_expire_time` (`expire_time`),
+ KEY `idx_tenant_id` (`tenant_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短链表';
+BEGIN;
+INSERT INTO `system_short_link` (id, short_code, original_url, title, description, domain, status, expire_time, click_count, last_click_time, create_time, update_time, creator, updater, deleted, forever, tenant_id) VALUES (4, '7YyTA2', 'https://gitee.com/wushuaiping/yudao-ui-admin-vue3', '123', '达十点', 'http://localhost:48080/app-api/system/short-link', 1, null, 13, '2025-12-04 18:01:34', '2025-12-04 11:30:31', '2025-12-04 16:43:48', '1', '1', false, true, 1);
+COMMIT;
+-- ----------------------------
+-- Table structure for system_short_link_access_log
+-- ----------------------------
+DROP TABLE IF EXISTS `system_short_link_access_log`;
+CREATE TABLE `system_short_link_access_log` (
+ `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号,数据库自增',
+ `short_link_id` bigint NOT NULL COMMENT '短链ID',
+ `short_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '短码',
+ `ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '访问IP',
+ `user_agent` text COLLATE utf8mb4_unicode_ci COMMENT '用户代理',
+ `referer` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源页面',
+ `access_time` datetime NOT NULL COMMENT '访问时间',
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
+ `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
+ `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
+ `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+ `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
+ PRIMARY KEY (`id`),
+ KEY `idx_short_link_id` (`short_link_id`),
+ KEY `idx_short_code` (`short_code`),
+ KEY `idx_access_time` (`access_time`),
+ KEY `idx_ip` (`ip`),
+ KEY `idx_create_time` (`create_time`),
+ KEY `idx_tenant_id` (`tenant_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短链访问记录表';
+
+BEGIN;
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (13, 4, '7YyTA2', '182.133.161.101', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', 'http://localhost/', '2025-12-04 16:00:47', '2025-12-04 16:00:47', '2025-12-04 16:25:39', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (14, 4, '7YyTA2', '182.133.161.101', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', 'http://localhost/', '2025-12-04 16:02:45', '2025-12-04 16:02:46', '2025-12-04 16:25:39', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (15, 4, '7YyTA2', '182.133.161.101', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', 'http://localhost/', '2025-12-04 16:20:13', '2025-12-04 16:20:13', '2025-12-04 16:25:39', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (16, 4, '7YyTA2', '175.0.199.149', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', 'http://localhost/', '2025-12-04 16:24:42', '2025-12-04 16:24:42', '2025-12-04 17:05:37', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (17, 4, '7YyTA2', '183.42.134.99', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', 'http://localhost/', '2025-12-04 16:24:52', '2025-12-04 16:24:52', '2025-12-04 17:05:29', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (18, 4, '7YyTA2', '183.42.134.99', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Trae/1.104.3 Chrome/138.0.7204.251 Electron/37.6.1 Safari/537.36', null, '2025-12-04 16:28:50', '2025-12-04 16:28:50', '2025-12-04 17:05:29', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (19, 4, '7YyTA2', '39.144.177.47', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Trae/1.104.3 Chrome/138.0.7204.251 Electron/37.6.1 Safari/537.36', null, '2025-12-04 16:29:09', '2025-12-04 16:29:09', '2025-12-04 17:05:29', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (20, 4, '7YyTA2', '39.148.224.219', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Trae/1.104.3 Chrome/138.0.7204.251 Electron/37.6.1 Safari/537.36', 'http://localhost/', '2025-12-04 16:29:20', '2025-12-04 16:29:20', '2025-12-04 17:05:29', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (21, 4, '7YyTA2', '171.94.147.206', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Trae/1.104.3 Chrome/138.0.7204.251 Electron/37.6.1 Safari/537.36', null, '2025-12-04 16:29:25', '2025-12-04 16:29:25', '2025-12-04 17:05:29', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (22, 4, '7YyTA2', '45.201.209.154', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Trae/1.104.3 Chrome/138.0.7204.251 Electron/37.6.1 Safari/537.36', 'http://localhost/', '2025-12-04 16:29:39', '2025-12-04 16:29:39', '2025-12-04 17:05:29', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (23, 4, '7YyTA2', '123.149.30.187', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', 'http://localhost/', '2025-12-04 16:43:59', '2025-12-04 16:43:59', '2025-12-04 17:05:29', null, null, false, 1);
+INSERT INTO `system_short_link_access_log` (id, short_link_id, short_code, ip, user_agent, referer, access_time, create_time, update_time, creator, updater, deleted, tenant_id) VALUES (24, 4, '7YyTA2', '127.0.0.1', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', 'http://localhost/', '2025-12-04 18:01:34', '2025-12-04 18:01:34', '2025-12-04 18:01:34', null, null, false, 1);
+COMMIT;
+
-- ----------------------------
-- Table structure for yudao_demo01_contact
-- ----------------------------
diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
index c89a5dcdb..fd0f038f0 100644
--- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
+++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
@@ -4,7 +4,7 @@ import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
* System 错误码枚举类
- *
+ *
* system 系统,使用 1-002-000-000 段
*/
public interface ErrorCodeConstants {
@@ -49,7 +49,7 @@ public interface ErrorCodeConstants {
// ========== 部门模块 1-002-004-000 ==========
ErrorCode DEPT_NAME_DUPLICATE = new ErrorCode(1_002_004_000, "已经存在该名字的部门");
- ErrorCode DEPT_PARENT_NOT_EXITS = new ErrorCode(1_002_004_001,"父级部门不存在");
+ ErrorCode DEPT_PARENT_NOT_EXITS = new ErrorCode(1_002_004_001, "父级部门不存在");
ErrorCode DEPT_NOT_FOUND = new ErrorCode(1_002_004_002, "当前部门不存在");
ErrorCode DEPT_EXITS_CHILDREN = new ErrorCode(1_002_004_003, "存在子部门,无法删除");
ErrorCode DEPT_PARENT_ERROR = new ErrorCode(1_002_004_004, "不能设置自己为父部门");
@@ -168,4 +168,13 @@ public interface ErrorCodeConstants {
// ========== 站内信发送 1-002-028-000 ==========
ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_028_000, "模板参数({})缺失");
+ // ========== 短链 1-002-029-000 ==========
+ ErrorCode SHORT_LINK_NOT_EXISTS = new ErrorCode(1_002_029_000, "短链不存在");
+ ErrorCode SHORT_LINK_CODE_IS_EXIST = new ErrorCode(1_002_029_001, "短链已存在");
+ ErrorCode SHORT_LINK_TIME_IS_NULL = new ErrorCode(1_002_029_002, "请选择失效时间");
+ ErrorCode SHORT_LINK_CODE_FORMAT_ERROR = new ErrorCode(1_002_029_003, "自定义短码格式不正确");
+ ErrorCode SHORT_LINK_CODE_GENERATE_ERROR = new ErrorCode(1_002_029_004, "生成短码失败,请重试");
+ ErrorCode SHORT_LINK_DISABLED = new ErrorCode(1_002_029_005, "短链已禁用");
+ ErrorCode SHORT_LINK_EXPIRED = new ErrorCode(1_002_029_006, "短链已过期");
+
}
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/ShortLinkController.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/ShortLinkController.java
new file mode 100644
index 000000000..b58f6d03c
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/ShortLinkController.java
@@ -0,0 +1,211 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
+import cn.iocoder.yudao.module.system.controller.admin.shortlink.vo.*;
+import cn.iocoder.yudao.module.system.dal.dataobject.shortlink.ShortLinkDO;
+import cn.iocoder.yudao.module.system.service.shortlink.ShortLinkService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+/**
+ * 管理后台 - 短链
+ *
+ * @author 吴帅苹
+ */
+@Tag(name = "管理后台 - 短链")
+@RestController
+@RequestMapping("/system/short-link")
+@Validated
+public class ShortLinkController {
+
+ @Resource
+ private ShortLinkService shortLinkService;
+
+ @PostMapping("/create")
+ @Operation(summary = "创建短链")
+ @PreAuthorize("@ss.hasPermission('system:short-link:create')")
+ public CommonResult createShortLink(@Valid @RequestBody ShortLinkCreateReqVO createReqVO) {
+ return success(shortLinkService.createShortLink(createReqVO));
+ }
+
+ @PutMapping("/domain")
+ @Operation(summary = "修改短链域名")
+ @PreAuthorize("@ss.hasPermission('system:short-link:update')")
+ public CommonResult updateShortLinkDomain(@RequestParam("newDomain") String newDomain) {
+ shortLinkService.updateShortLinkDomain(newDomain);
+ return success(true);
+ }
+
+ @GetMapping("/domain")
+ @Operation(summary = "获取短链域名")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult getShortLinkDomain() {
+ return success(shortLinkService.getShortLinkDomain());
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "更新短链")
+ @PreAuthorize("@ss.hasPermission('system:short-link:update')")
+ public CommonResult updateShortLink(@Valid @RequestBody ShortLinkUpdateReqVO updateReqVO) {
+ shortLinkService.updateShortLink(updateReqVO);
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "删除短链")
+ @Parameter(name = "id", description = "编号", required = true)
+ @PreAuthorize("@ss.hasPermission('system:short-link:delete')")
+ public CommonResult deleteShortLink(@RequestParam("id") Long id) {
+ shortLinkService.deleteShortLink(id);
+ return success(true);
+ }
+
+ @GetMapping("/get")
+ @Operation(summary = "获得短链")
+ @Parameter(name = "id", description = "编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult getShortLink(@RequestParam("id") Long id) {
+ ShortLinkDO shortLink = shortLinkService.getShortLink(id);
+ return success(BeanUtils.toBean(shortLink, ShortLinkRespVO.class));
+ }
+
+ @GetMapping("/page")
+ @Operation(summary = "获得短链分页")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult> getShortLinkPage(@Valid ShortLinkPageReqVO pageReqVO) {
+ PageResult pageResult = shortLinkService.getShortLinkPage(pageReqVO);
+ return success(BeanUtils.toBean(pageResult, ShortLinkRespVO.class));
+ }
+
+ @GetMapping("/export-excel")
+ @Operation(summary = "导出短链 Excel")
+ @PreAuthorize("@ss.hasPermission('system:short-link:export')")
+ public void exportShortLinkExcel(@Valid ShortLinkPageReqVO pageReqVO,
+ HttpServletResponse response) throws IOException {
+ pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
+ List list = shortLinkService.getShortLinkPage(pageReqVO).getList();
+ // 导出 Excel
+ ExcelUtils.write(response, "短链.xls", "数据", ShortLinkRespVO.class,
+ BeanUtils.toBean(list, ShortLinkRespVO.class));
+ }
+
+ @GetMapping("/by-code")
+ @Operation(summary = "根据短码获取短链")
+ @Parameter(name = "shortCode", description = "短码", required = true, example = "abc123")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult getShortLinkByCode(@RequestParam("shortCode") String shortCode) {
+ ShortLinkDO shortLink = shortLinkService.getShortLinkByCode(shortCode);
+ return success(BeanUtils.toBean(shortLink, ShortLinkRespVO.class));
+ }
+
+ @PostMapping("/batch-delete")
+ @Operation(summary = "批量删除短链")
+ @Parameter(name = "ids", description = "编号数组", required = true)
+ @PreAuthorize("@ss.hasPermission('system:short-link:delete')")
+ public CommonResult deleteShortLinks(@RequestBody Long[] ids) {
+ shortLinkService.deleteShortLinks(ids);
+ return success(true);
+ }
+
+ @PutMapping("/update-status")
+ @Operation(summary = "修改短链状态")
+ @PreAuthorize("@ss.hasPermission('system:short-link:update')")
+ public CommonResult updateShortLinkStatus(@RequestParam("id") Long id,
+ @RequestParam("status") Integer status) {
+ shortLinkService.updateShortLinkStatus(id, status);
+ return success(true);
+ }
+
+
+ // ==================== 统计分析相关接口 ====================
+
+ @GetMapping("/statistics/overview")
+ @Operation(summary = "获取短链概览统计")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult getShortLinkOverviewStats(
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @RequestParam(value = "startTime", required = false) LocalDateTime startTime,
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @RequestParam(value = "endTime", required = false) LocalDateTime endTime) {
+ return success(shortLinkService.getShortLinkOverviewStats(startTime, endTime));
+ }
+
+ @GetMapping("/statistics/trend")
+ @Operation(summary = "获取短链趋势统计")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult> getShortLinkTrendStats(@RequestParam(defaultValue = "7") Integer days) {
+ return success(shortLinkService.getShortLinkTrendStats(days));
+ }
+
+ @GetMapping("/statistics/status")
+ @Operation(summary = "获取短链状态分布统计")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult getShortLinkStatusStats() {
+ return success(shortLinkService.getShortLinkStatusStats());
+ }
+
+ @GetMapping("/statistics/region")
+ @Operation(summary = "获取短链地域访问统计")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult getShortLinkRegionStats(
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @RequestParam(value = "startTime", required = false) LocalDateTime startTime,
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @RequestParam(value = "endTime", required = false) LocalDateTime endTime) {
+ return success(shortLinkService.getShortLinkRegionStats(startTime, endTime));
+ }
+
+ @GetMapping("/statistics/device")
+ @Operation(summary = "获取短链设备类型统计")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult getShortLinkDeviceStats(
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @RequestParam(value = "startTime", required = false) LocalDateTime startTime,
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @RequestParam(value = "endTime", required = false) LocalDateTime endTime) {
+ return success(shortLinkService.getShortLinkDeviceStats(startTime, endTime));
+ }
+
+ @GetMapping("/statistics/hour")
+ @Operation(summary = "获取短链访问时段统计")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult getShortLinkHourStats(
+ @DateTimeFormat(pattern = "yyyy-MM-dd") @RequestParam(value = "date", required = false) LocalDate date) {
+ return success(shortLinkService.getShortLinkHourStats(date));
+ }
+
+ // ==================== 访问日志相关接口 ====================
+
+ @GetMapping("/access-log/page")
+ @Operation(summary = "获得短链访问日志分页")
+ @PreAuthorize("@ss.hasPermission('system:short-link:query')")
+ public CommonResult> getShortLinkAccessLogPage(@Valid ShortLinkAccessLogPageReqVO pageReqVO) {
+ return success(shortLinkService.getShortLinkAccessLogPage(pageReqVO));
+ }
+
+ @GetMapping("/access-log/export-excel")
+ @Operation(summary = "导出短链访问日志 Excel")
+ @PreAuthorize("@ss.hasPermission('system:short-link:export')")
+ public void exportShortLinkAccessLogExcel(@Valid ShortLinkAccessLogPageReqVO pageReqVO,
+ HttpServletResponse response) throws IOException {
+ pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
+ List list = shortLinkService.getShortLinkAccessLogPage(pageReqVO).getList();
+ // 导出 Excel
+ ExcelUtils.write(response, "短链访问日志.xls", "数据", ShortLinkAccessLogRespVO.class, list);
+ }
+
+}
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkAccessLogPageReqVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkAccessLogPageReqVO.java
new file mode 100644
index 000000000..1ead73ac6
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkAccessLogPageReqVO.java
@@ -0,0 +1,33 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - 短链访问记录分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class ShortLinkAccessLogPageReqVO extends PageParam {
+
+ @Schema(description = "短链ID", example = "1024")
+ private Long shortLinkId;
+
+ @Schema(description = "短码", example = "abc123")
+ private String shortCode;
+
+ @Schema(description = "访问IP", example = "192.168.1.1")
+ private String ip;
+
+ @Schema(description = "访问时间")
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+ private LocalDateTime[] accessTime;
+
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkAccessLogRespVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkAccessLogRespVO.java
new file mode 100644
index 000000000..c455675e7
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkAccessLogRespVO.java
@@ -0,0 +1,42 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 短链访问记录 Response VO")
+@Data
+public class ShortLinkAccessLogRespVO {
+
+ @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "短链ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long shortLinkId;
+
+ @Schema(description = "短码", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123")
+ private String shortCode;
+
+ @Schema(description = "访问IP", example = "192.168.1.1")
+ private String ip;
+
+ @Schema(description = "用户代理", example = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
+ private String userAgent;
+
+ @Schema(description = "来源页面", example = "https://www.google.com")
+ private String referer;
+
+ @Schema(description = "国家", example = "中国")
+ private String country;
+
+ @Schema(description = "省份", example = "四川省")
+ private String province;
+
+ @Schema(description = "城市", example = "成都市")
+ private String city;
+
+ @Schema(description = "访问时间", requiredMode = Schema.RequiredMode.REQUIRED)
+ private LocalDateTime accessTime;
+
+}
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkCreateReqVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkCreateReqVO.java
new file mode 100644
index 000000000..459fc9995
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkCreateReqVO.java
@@ -0,0 +1,49 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import org.hibernate.validator.constraints.URL;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - 短链创建 Request VO")
+@Data
+public class ShortLinkCreateReqVO {
+
+ @Schema(description = "原始URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.example.com/very/long/url")
+ @NotBlank(message = "原始URL不能为空")
+ @URL(message = "原始URL格式不正确")
+ @Size(max = 2048, message = "原始URL长度不能超过2048个字符")
+ private String originalUrl;
+
+ @Schema(description = "自定义短码", example = "abc123")
+ @Size(max = 32, message = "自定义短码长度不能超过32个字符")
+ private String customShortCode;
+
+ @Schema(description = "链接标题", example = "示例网站")
+ @Size(max = 255, message = "链接标题长度不能超过255个字符")
+ private String title;
+
+ @Schema(description = "链接描述", example = "这是一个示例网站的描述")
+ @Size(max = 500, message = "链接描述长度不能超过500个字符")
+ private String description;
+
+ @Schema(description = "是否永久生效", example = "true")
+ @NotNull(message = "请选择是否永久生效")
+ private Boolean forever;
+
+ @Schema(description = "域名配置", example = "short.example.com")
+ @Size(max = 100, message = "域名配置长度不能超过100个字符")
+ private String domain;
+
+ @Schema(description = "过期时间", example = "2024-12-31T23:59:59")
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+ private LocalDateTime expireTime;
+
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkDeviceStatsRespVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkDeviceStatsRespVO.java
new file mode 100644
index 000000000..b53637f73
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkDeviceStatsRespVO.java
@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 短链设备类型统计 Response VO
+ *
+ * @author 吴帅苹
+ */
+@Schema(description = "管理后台 - 短链设备类型统计 Response VO")
+@Data
+@Builder
+public class ShortLinkDeviceStatsRespVO {
+
+ @Schema(description = "设备类型分布数据列表", requiredMode = Schema.RequiredMode.REQUIRED)
+ private List deviceData;
+
+ @Schema(description = "设备数据项")
+ @Data
+ @Builder
+ public static class DeviceData {
+
+ @Schema(description = "设备类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "Mobile")
+ private String deviceType;
+
+ @Schema(description = "设备名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "移动设备")
+ private String deviceName;
+
+ @Schema(description = "访问量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1234")
+ private Long visitCount;
+
+ @Schema(description = "占比", requiredMode = Schema.RequiredMode.REQUIRED, example = "65.4")
+ private Double percentage;
+ }
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkHourStatsRespVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkHourStatsRespVO.java
new file mode 100644
index 000000000..573d71712
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkHourStatsRespVO.java
@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 短链访问时段统计 Response VO
+ *
+ * @author 吴帅苹
+ */
+@Schema(description = "管理后台 - 短链访问时段统计 Response VO")
+@Data
+@Builder
+public class ShortLinkHourStatsRespVO {
+
+ @Schema(description = "时段分布数据列表", requiredMode = Schema.RequiredMode.REQUIRED)
+ private List hourData;
+
+ @Schema(description = "时段数据项")
+ @Data
+ @Builder
+ public static class HourData {
+
+ @Schema(description = "小时", requiredMode = Schema.RequiredMode.REQUIRED, example = "14")
+ private Integer hour;
+
+ @Schema(description = "时段描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "14:00-15:00")
+ private String hourRange;
+
+ @Schema(description = "访问量", requiredMode = Schema.RequiredMode.REQUIRED, example = "234")
+ private Long visitCount;
+
+ @Schema(description = "占比", requiredMode = Schema.RequiredMode.REQUIRED, example = "8.5")
+ private Double percentage;
+ }
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkOverviewStatsRespVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkOverviewStatsRespVO.java
new file mode 100644
index 000000000..f80fff173
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkOverviewStatsRespVO.java
@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 短链概览统计 Response VO
+ *
+ * @author 吴帅苹
+ */
+@Schema(description = "管理后台 - 短链概览统计 Response VO")
+@Data
+@Builder
+public class ShortLinkOverviewStatsRespVO {
+
+ @Schema(description = "总短链数", requiredMode = Schema.RequiredMode.REQUIRED, example = "1234")
+ private Long totalLinks;
+
+ @Schema(description = "总点击量", requiredMode = Schema.RequiredMode.REQUIRED, example = "56789")
+ private Long totalClicks;
+
+ @Schema(description = "活跃短链数", requiredMode = Schema.RequiredMode.REQUIRED, example = "890")
+ private Long activeLinks;
+
+ @Schema(description = "平均点击率", requiredMode = Schema.RequiredMode.REQUIRED, example = "45.6")
+ private Double avgClickRate;
+
+ @Schema(description = "今日新增短链", requiredMode = Schema.RequiredMode.REQUIRED, example = "23")
+ private Long todayNewLinks;
+
+ @Schema(description = "今日点击量", requiredMode = Schema.RequiredMode.REQUIRED, example = "456")
+ private Long todayClicks;
+
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkPageReqVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkPageReqVO.java
new file mode 100644
index 000000000..39d3713fd
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkPageReqVO.java
@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - 短链分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class ShortLinkPageReqVO extends PageParam {
+
+ @Schema(description = "短码", example = "abc123")
+ private String shortCode;
+
+ @Schema(description = "原始URL", example = "https://www.example.com")
+ private String originalUrl;
+
+ @Schema(description = "链接标题", example = "示例网站")
+ private String title;
+
+ @Schema(description = "状态", example = "1")
+ private Boolean status;
+
+ @Schema(description = "创建者", example = "admin")
+ private String creator;
+
+ @Schema(description = "创建时间")
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+ private LocalDateTime[] createTime;
+
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkRegionStatsRespVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkRegionStatsRespVO.java
new file mode 100644
index 000000000..9f4516b76
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkRegionStatsRespVO.java
@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 短链地域访问统计 Response VO
+ *
+ * @author 吴帅苹
+ */
+@Schema(description = "管理后台 - 短链地域访问统计 Response VO")
+@Data
+@Builder
+public class ShortLinkRegionStatsRespVO {
+
+ @Schema(description = "地域分布数据列表", requiredMode = Schema.RequiredMode.REQUIRED)
+ private List regionData;
+
+ @Schema(description = "地域数据项")
+ @Data
+ @Builder
+ public static class RegionData {
+
+ @Schema(description = "省份", requiredMode = Schema.RequiredMode.REQUIRED, example = "四川省")
+ private String province;
+
+ @Schema(description = "城市", requiredMode = Schema.RequiredMode.REQUIRED, example = "成都市")
+ private String city;
+
+ @Schema(description = "访问量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1234")
+ private Long visitCount;
+
+ @Schema(description = "占比", requiredMode = Schema.RequiredMode.REQUIRED, example = "25.6")
+ private Double percentage;
+ }
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkRespVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkRespVO.java
new file mode 100644
index 000000000..8c1c341b3
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkRespVO.java
@@ -0,0 +1,54 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 短链 Response VO")
+@Data
+public class ShortLinkRespVO {
+
+ @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "短码", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123")
+ private String shortCode;
+
+ @Schema(description = "原始URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.example.com/very/long/url")
+ private String originalUrl;
+
+ @Schema(description = "链接标题", example = "示例网站")
+ private String title;
+
+ @Schema(description = "链接描述", example = "这是一个示例网站的描述")
+ private String description;
+
+ @Schema(description = "域名配置", example = "short.example.com")
+ private String domain;
+
+ @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Boolean status;
+
+ @Schema(description = "过期时间", example = "2024-12-31T23:59:59")
+ private LocalDateTime expireTime;
+
+ @Schema(description = "点击次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
+ private Long clickCount;
+
+ @Schema(description = "是否永久生效", example = "true")
+ private Boolean forever;
+
+ @Schema(description = "最后点击时间", example = "2024-01-15T10:30:00")
+ private LocalDateTime lastClickTime;
+
+ @Schema(description = "创建者", example = "admin")
+ private String creator;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
+ private LocalDateTime createTime;
+
+ @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)
+ private LocalDateTime updateTime;
+
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkStatusStatsRespVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkStatusStatsRespVO.java
new file mode 100644
index 000000000..4691a6ab5
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkStatusStatsRespVO.java
@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 短链状态分布统计 Response VO
+ *
+ * @author 吴帅苹
+ */
+@Schema(description = "管理后台 - 短链状态分布统计 Response VO")
+@Data
+@Builder
+public class ShortLinkStatusStatsRespVO {
+
+ @Schema(description = "状态分布数据列表", requiredMode = Schema.RequiredMode.REQUIRED)
+ private List statusData;
+
+ @Schema(description = "状态数据项")
+ @Data
+ @Builder
+ public static class StatusData {
+
+ @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "启用")
+ private String statusName;
+
+ @Schema(description = "状态值", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer statusValue;
+
+ @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "890")
+ private Long count;
+
+ @Schema(description = "占比", requiredMode = Schema.RequiredMode.REQUIRED, example = "72.3")
+ private Double percentage;
+ }
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkTrendStatsRespVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkTrendStatsRespVO.java
new file mode 100644
index 000000000..36058493c
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkTrendStatsRespVO.java
@@ -0,0 +1,37 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 短链趋势统计 Response VO
+ *
+ * @author 吴帅苹
+ */
+@Schema(description = "管理后台 - 短链趋势统计 Response VO")
+@Data
+@Builder
+public class ShortLinkTrendStatsRespVO {
+
+ @Schema(description = "趋势数据列表", requiredMode = Schema.RequiredMode.REQUIRED)
+ private List trendData;
+
+ @Schema(description = "趋势数据项")
+ @Data
+ @Builder
+ public static class TrendData {
+
+ @Schema(description = "日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-01-01")
+ private LocalDate date;
+
+ @Schema(description = "新增短链数", requiredMode = Schema.RequiredMode.REQUIRED, example = "23")
+ private Long createCount;
+
+ @Schema(description = "点击量", requiredMode = Schema.RequiredMode.REQUIRED, example = "456")
+ private Long clickCount;
+ }
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkUpdateReqVO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkUpdateReqVO.java
new file mode 100644
index 000000000..c07275774
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/admin/shortlink/vo/ShortLinkUpdateReqVO.java
@@ -0,0 +1,23 @@
+package cn.iocoder.yudao.module.system.controller.admin.shortlink.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+@Schema(description = "管理后台 - 短链更新 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class ShortLinkUpdateReqVO extends ShortLinkCreateReqVO {
+
+ @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "编号不能为空")
+ private Long id;
+
+ @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "状态不能为空")
+ private Boolean status;
+
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/shortlink/AppShortLinkController.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/shortlink/AppShortLinkController.java
new file mode 100644
index 000000000..e7f3630a9
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/controller/app/shortlink/AppShortLinkController.java
@@ -0,0 +1,84 @@
+package cn.iocoder.yudao.module.system.controller.app.shortlink;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import cn.iocoder.yudao.module.system.service.shortlink.ShortLinkService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.annotation.security.PermitAll;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.error;
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+/**
+ * 用户 APP - 短链访问
+ *
+ * @author 吴帅苹
+ */
+@Tag(name = "用户 APP - 短链访问")
+@RestController
+@RequestMapping("/system/short-link")
+@Validated
+@Slf4j
+@TenantIgnore
+public class AppShortLinkController {
+
+ @Resource
+ private ShortLinkService shortLinkService;
+
+ @GetMapping("/{shortCode}")
+ @Operation(summary = "短链跳转")
+ @Parameter(name = "shortCode", description = "短码", required = true, example = "abc123")
+ @PermitAll
+ public CommonResult redirectShortLink(@PathVariable("shortCode") String shortCode,
+ HttpServletRequest request,
+ HttpServletResponse response) {
+ try {
+ String ip = ServletUtils.getClientIP(request);
+ String userAgent = request.getHeader("User-Agent");
+ String referer = request.getHeader("Referer");
+
+ String originalUrl = shortLinkService.accessShortLink(shortCode, ip, userAgent, referer);
+ response.sendRedirect(originalUrl);
+ } catch (Exception e) {
+ log.error("短链跳转失败,短码:{},错误:{}", shortCode, e.getMessage());
+ return error(HttpServletResponse.SC_NOT_FOUND, "短链不存在或已失效");
+ }
+ return success(true);
+ }
+
+ @GetMapping("/preview/{shortCode}")
+ @Operation(summary = "短链预览")
+ @Parameter(name = "shortCode", description = "短码", required = true, example = "abc123")
+ public CommonResult previewShortLink(@PathVariable("shortCode") String shortCode) {
+ try {
+ // 获取短链信息但不记录访问
+ var shortLink = shortLinkService.getShortLinkByCode(shortCode);
+ if (shortLink == null) {
+ return CommonResult.error(404, "短链不存在");
+ }
+
+ if (!shortLink.getStatus()) {
+ return CommonResult.error(400, "短链已禁用");
+ }
+
+ return success(shortLink.getOriginalUrl());
+
+ } catch (Exception e) {
+ log.error("短链预览失败,短码:{},错误:{}", shortCode, e.getMessage());
+ return CommonResult.error(500, "预览失败:" + e.getMessage());
+ }
+ }
+
+}
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/shortlink/ShortLinkAccessLogDO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/shortlink/ShortLinkAccessLogDO.java
new file mode 100644
index 000000000..b8b11551f
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/shortlink/ShortLinkAccessLogDO.java
@@ -0,0 +1,61 @@
+package cn.iocoder.yudao.module.system.dal.dataobject.shortlink;
+
+import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * 短链访问记录表
+ * 用于记录短链的访问日志,便于统计分析
+ *
+ * @author 吴帅苹
+ */
+@TableName("system_short_link_access_log")
+@KeySequence("system_short_link_access_log_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ShortLinkAccessLogDO extends TenantBaseDO {
+
+ /**
+ * 编号,数据库自增
+ */
+ private Long id;
+
+ /**
+ * 短链ID
+ */
+ private Long shortLinkId;
+
+ /**
+ * 短码
+ */
+ private String shortCode;
+
+ /**
+ * 访问IP
+ */
+ private String ip;
+
+ /**
+ * 用户代理
+ */
+ private String userAgent;
+
+ /**
+ * 来源页面
+ */
+ private String referer;
+
+ /**
+ * 访问时间
+ */
+ private LocalDateTime accessTime;
+
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/shortlink/ShortLinkDO.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/shortlink/ShortLinkDO.java
new file mode 100644
index 000000000..66c5f6d4d
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/shortlink/ShortLinkDO.java
@@ -0,0 +1,84 @@
+package cn.iocoder.yudao.module.system.dal.dataobject.shortlink;
+
+import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * 短链表
+ * 用于存储短链接的基本信息和统计数据
+ *
+ * @author 吴帅苹
+ */
+@TableName("system_short_link")
+@KeySequence("system_short_link_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ShortLinkDO extends TenantBaseDO {
+
+ /**
+ * 编号,数据库自增
+ */
+ private Long id;
+
+ /**
+ * 短码
+ */
+ private String shortCode;
+
+ /**
+ * 原始URL
+ */
+ private String originalUrl;
+
+ /**
+ * 链接标题
+ */
+ private String title;
+
+ /**
+ * 链接描述
+ */
+ private String description;
+
+ /**
+ * 域名配置
+ */
+ private String domain;
+
+ /**
+ * 状态(true-启用,false-禁用)
+ */
+ private Boolean status;
+
+ /**
+ * 永久链接(true-是,false-否)
+ */
+ private Boolean forever;
+
+ /**
+ * 过期时间
+ */
+ @TableField(updateStrategy = FieldStrategy.ALWAYS, insertStrategy = FieldStrategy.ALWAYS)
+ private LocalDateTime expireTime;
+
+ /**
+ * 点击次数
+ */
+ private Long clickCount;
+
+ /**
+ * 最后点击时间
+ */
+ private LocalDateTime lastClickTime;
+
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/shortlink/ShortLinkAccessLogMapper.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/shortlink/ShortLinkAccessLogMapper.java
new file mode 100644
index 000000000..967098113
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/shortlink/ShortLinkAccessLogMapper.java
@@ -0,0 +1,43 @@
+package cn.iocoder.yudao.module.system.dal.mysql.shortlink;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.system.controller.admin.shortlink.vo.ShortLinkAccessLogPageReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.shortlink.ShortLinkAccessLogDO;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 短链访问记录 Mapper
+ *
+ * @author 吴帅苹
+ */
+@Mapper
+public interface ShortLinkAccessLogMapper extends BaseMapperX {
+
+ default PageResult selectPage(ShortLinkAccessLogPageReqVO reqVO) {
+ return selectPage(reqVO, new LambdaQueryWrapperX()
+ .eqIfPresent(ShortLinkAccessLogDO::getShortLinkId, reqVO.getShortLinkId())
+ .likeIfPresent(ShortLinkAccessLogDO::getShortCode, reqVO.getShortCode())
+ .likeIfPresent(ShortLinkAccessLogDO::getIp, reqVO.getIp())
+ .betweenIfPresent(ShortLinkAccessLogDO::getAccessTime, reqVO.getAccessTime())
+ .orderByDesc(ShortLinkAccessLogDO::getId));
+ }
+
+ /**
+ * 统计在访问时间范围内的访问数量
+ */
+ default Long selectCountByAccessTimeBetween(java.time.LocalDateTime start, java.time.LocalDateTime end) {
+ return selectCount(new LambdaQueryWrapperX()
+ .between(ShortLinkAccessLogDO::getAccessTime, start, end));
+ }
+
+ /**
+ * 查询指定访问时间范围内的访问日志列表
+ */
+ default java.util.List selectListByAccessTimeBetween(java.time.LocalDateTime start, java.time.LocalDateTime end) {
+ return selectList(new LambdaQueryWrapperX()
+ .between(ShortLinkAccessLogDO::getAccessTime, start, end));
+ }
+
+}
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/shortlink/ShortLinkMapper.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/shortlink/ShortLinkMapper.java
new file mode 100644
index 000000000..defc34fab
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/shortlink/ShortLinkMapper.java
@@ -0,0 +1,86 @@
+package cn.iocoder.yudao.module.system.dal.mysql.shortlink;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.system.controller.admin.shortlink.vo.ShortLinkPageReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.shortlink.ShortLinkDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.Objects;
+
+/**
+ * 短链 Mapper
+ *
+ * @author 吴帅苹
+ */
+@Mapper
+public interface ShortLinkMapper extends BaseMapperX {
+
+ default PageResult selectPage(ShortLinkPageReqVO reqVO) {
+ return selectPage(reqVO, new LambdaQueryWrapperX()
+ .likeIfPresent(ShortLinkDO::getShortCode, reqVO.getShortCode())
+ .likeIfPresent(ShortLinkDO::getOriginalUrl, reqVO.getOriginalUrl())
+ .likeIfPresent(ShortLinkDO::getTitle, reqVO.getTitle())
+ .eqIfPresent(ShortLinkDO::getStatus, reqVO.getStatus())
+ .eqIfPresent(ShortLinkDO::getCreator, reqVO.getCreator())
+ .betweenIfPresent(ShortLinkDO::getCreateTime, reqVO.getCreateTime())
+ .orderByDesc(ShortLinkDO::getId));
+ }
+
+ default ShortLinkDO selectByShortCode(String shortCode) {
+ return selectOne(ShortLinkDO::getShortCode, shortCode);
+ }
+
+ // ==================== 统计相关查询方法 ====================
+
+ /**
+ * 统计总短链数
+ */
+ default Long selectTotalCount() {
+ return selectCount();
+ }
+
+ /**
+ * 统计活跃短链数(有点击记录的)
+ */
+ default Long selectActiveCount() {
+ return selectCount(new LambdaQueryWrapperX()
+ .gt(ShortLinkDO::getClickCount, 0));
+ }
+
+ /**
+ * 统计启用状态的短链数
+ */
+ default Long selectEnabledCount() {
+ return selectCount(ShortLinkDO::getStatus, 1);
+ }
+
+ /**
+ * 统计禁用状态的短链数
+ */
+ default Long selectDisabledCount() {
+ return selectCount(ShortLinkDO::getStatus, 0);
+ }
+
+ /**
+ * 统计总点击量
+ */
+ default Long selectTotalClickCount() {
+ return selectObjs(new LambdaQueryWrapperX()
+ .select(ShortLinkDO::getClickCount))
+ .stream()
+ .filter(Objects::nonNull)
+ .mapToLong(obj -> (Long) obj)
+ .sum();
+ }
+
+ /**
+ * 统计在创建时间范围内的短链数量
+ */
+ default Long selectCountByCreateTimeBetween(java.time.LocalDateTime start, java.time.LocalDateTime end) {
+ return selectCount(new LambdaQueryWrapperX()
+ .between(ShortLinkDO::getCreateTime, start, end));
+ }
+
+}
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java
index 1cd58306b..d1f3cbdc4 100644
--- a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java
@@ -101,10 +101,34 @@ public interface RedisKeyConstants {
/**
* 小程序订阅模版的缓存
- *
+ *
* KEY 格式:wxa_subscribe_template:{userType}
* VALUE 数据格式 String, 模版信息
*/
String WXA_SUBSCRIBE_TEMPLATE = "wxa_subscribe_template";
+ /**
+ * 短链域名的缓存
+ *
+ * KEY 短链域名: short_link_domain:{tenantId}
+ * VALUE 数据格式:String 域名信息
+ */
+ String SHORT_LINK_DOMAIN = "short_link_domain";
+
+ /**
+ * 短链的缓存
+ *
+ * KEY 短链编号: short_link_code_cache:{code}
+ * VALUE 数据格式:String 短链信息
+ */
+ String SHORT_LINK_CODE_CACHE = "short_link_code_cache";
+
+ /**
+ * 短链的访问次数的缓存
+ *
+ * KEY 短链编号: short_link_access_rate:{code}
+ * VALUE 数据格式:String 短链的访问次数
+ */
+ String SHORT_LINK_ACCESS_RATE = "short_link_access_rate";
+
}
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/shortlink/ShortLinkService.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/shortlink/ShortLinkService.java
new file mode 100644
index 000000000..155c350c2
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/shortlink/ShortLinkService.java
@@ -0,0 +1,158 @@
+package cn.iocoder.yudao.module.system.service.shortlink;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.system.controller.admin.shortlink.vo.*;
+import cn.iocoder.yudao.module.system.dal.dataobject.shortlink.ShortLinkDO;
+import jakarta.validation.Valid;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 短链 Service 接口
+ *
+ * @author 吴帅苹
+ */
+public interface ShortLinkService {
+
+ /**
+ * 创建短链
+ *
+ * @param createReqVO 创建信息
+ * @return 编号
+ */
+ Long createShortLink(@Valid ShortLinkCreateReqVO createReqVO);
+
+ /**
+ * 更新短链
+ *
+ * @param updateReqVO 更新信息
+ */
+ void updateShortLink(@Valid ShortLinkUpdateReqVO updateReqVO);
+
+ /**
+ * 删除短链
+ *
+ * @param id 编号
+ */
+ void deleteShortLink(Long id);
+
+ /**
+ * 获得短链
+ *
+ * @param id 编号
+ * @return 短链
+ */
+ ShortLinkDO getShortLink(Long id);
+
+ /**
+ * 获得短链分页
+ *
+ * @param pageReqVO 分页查询
+ * @return 短链分页
+ */
+ PageResult getShortLinkPage(ShortLinkPageReqVO pageReqVO);
+
+ /**
+ * 根据短码获取短链信息
+ *
+ * @param shortCode 短码
+ * @return 短链信息
+ */
+ ShortLinkDO getShortLinkByCode(String shortCode);
+
+ /**
+ * 访问短链(重定向并记录访问日志)
+ *
+ * @param shortCode 短码
+ * @param ip 访问IP
+ * @param userAgent 用户代理
+ * @param referer 来源页面
+ * @return 原始URL
+ */
+ String accessShortLink(String shortCode, String ip, String userAgent, String referer);
+
+ /**
+ * 获得短链访问记录分页
+ *
+ * @param pageReqVO 分页查询
+ * @return 访问记录分页
+ */
+ PageResult getShortLinkAccessLogPage(ShortLinkAccessLogPageReqVO pageReqVO);
+
+ /**
+ * 获取短链总览统计
+ *
+ * @param start 开始时间
+ * @param end 结束时间
+ * @return 总览统计信息
+ */
+ ShortLinkOverviewStatsRespVO getShortLinkOverviewStats(LocalDateTime start, LocalDateTime end);
+
+ /**
+ * 获取短链趋势统计
+ *
+ * @param days 统计天数
+ * @return 趋势统计信息
+ */
+ List getShortLinkTrendStats(Integer days);
+
+ /**
+ * 获取短链状态分布统计
+ *
+ * @return 状态分布统计信息
+ */
+ ShortLinkStatusStatsRespVO getShortLinkStatusStats();
+
+ /**
+ * 获取短链地域访问统计
+ *
+ * @return 地域访问统计信息
+ */
+ ShortLinkRegionStatsRespVO getShortLinkRegionStats(LocalDateTime start, LocalDateTime end);
+
+ /**
+ * 获取短链设备类型统计
+ *
+ * @return 设备类型统计信息
+ */
+ ShortLinkDeviceStatsRespVO getShortLinkDeviceStats(LocalDateTime start, LocalDateTime end);
+
+ /**
+ * 获取短链访问时段统计
+ *
+ * @return 访问时段统计信息
+ */
+ ShortLinkHourStatsRespVO getShortLinkHourStats(LocalDate date);
+
+ /**
+ * 批量删除短链
+ *
+ * @param ids 编号数组
+ */
+ void deleteShortLinks(Long[] ids);
+
+ /**
+ * 更新短链状态
+ *
+ * @param id 编号
+ * @param status 状态
+ */
+ void updateShortLinkStatus(Long id, Integer status);
+
+ /**
+ * 更新短链域名
+ *
+ * @param newDomain 新域名
+ */
+ void updateShortLinkDomain(String newDomain);
+
+ /**
+ * 获取短链域名
+ *
+ * @return 域名
+ *
+ */
+ String getShortLinkDomain();
+}
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/shortlink/ShortLinkServiceImpl.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/shortlink/ShortLinkServiceImpl.java
new file mode 100644
index 000000000..2901a7ee5
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/shortlink/ShortLinkServiceImpl.java
@@ -0,0 +1,615 @@
+package cn.iocoder.yudao.module.system.service.shortlink;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.ip.core.Area;
+import cn.iocoder.yudao.framework.ip.core.enums.AreaTypeEnum;
+import cn.iocoder.yudao.framework.ip.core.utils.IPUtils;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.yudao.module.system.controller.admin.shortlink.vo.*;
+import cn.iocoder.yudao.module.system.dal.dataobject.shortlink.ShortLinkAccessLogDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.shortlink.ShortLinkDO;
+import cn.iocoder.yudao.module.system.dal.mysql.shortlink.ShortLinkAccessLogMapper;
+import cn.iocoder.yudao.module.system.dal.mysql.shortlink.ShortLinkMapper;
+import cn.iocoder.yudao.module.system.util.shrotlink.ShortCodeUtils;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants.*;
+import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
+
+/**
+ * 短链 Service 实现类
+ *
+ * @author 吴帅苹
+ */
+@Service
+@Slf4j
+@RequiredArgsConstructor(onConstructor_ = @Lazy)
+public class ShortLinkServiceImpl implements ShortLinkService {
+
+ private final ShortLinkMapper shortLinkMapper;
+
+ private final ShortLinkAccessLogMapper shortLinkAccessLogMapper;
+
+ private final StringRedisTemplate redisTemplate;
+
+ @Value("${yudao.short-link.domain}")
+ private String domain;
+
+ /**
+ * 创建短链
+ * - 读取当前租户主域名(Redis 优先,空则回退到配置项)
+ * - 校验失效时间(非永久场景必须填写)
+ * - 生成唯一短码并保存
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long createShortLink(ShortLinkCreateReqVO createReqVO) {
+
+ // 获取租户
+ Long requiredTenantId = TenantContextHolder.getRequiredTenantId();
+
+ String domain = redisTemplate.opsForValue().get(SHORT_LINK_DOMAIN + ":" + requiredTenantId);
+ if (StrUtil.isBlank(domain)) {
+ domain = this.domain;
+ }
+
+ // 如果不是永久生效,失效时间必填
+ if (!createReqVO.getForever()) {
+ if (createReqVO.getExpireTime() == null) {
+ throw exception(SHORT_LINK_TIME_IS_NULL);
+ }
+ }
+
+ // 1. 生成短码
+ String shortCode = generateUniqueShortCode(createReqVO.getOriginalUrl(), createReqVO.getCustomShortCode());
+
+ // 2. 构建短链对象
+ ShortLinkDO shortLink = BeanUtils.toBean(createReqVO, ShortLinkDO.class);
+ shortLink.setShortCode(shortCode);
+ shortLink.setStatus(Boolean.TRUE); // 默认启用
+ shortLink.setClickCount(0L);
+ shortLink.setDomain(domain);
+
+ // 3. 插入数据库
+ shortLinkMapper.insert(shortLink);
+ return shortLink.getId();
+ }
+
+ /**
+ * 修改短链
+ * - 校验存在并处理永久/失效时间逻辑
+ * - 读取租户主域名并更新
+ * - 清理相关短码缓存
+ */
+ @Override
+ public void updateShortLink(ShortLinkUpdateReqVO updateReqVO) {
+ // 校验存在
+ validateShortLinkExists(updateReqVO.getId());
+ ShortLinkDO old = shortLinkMapper.selectById(updateReqVO.getId());
+
+ // 如果不是永久生效,失效时间必填
+ if (!updateReqVO.getForever()) {
+ if (updateReqVO.getExpireTime() == null) {
+ throw exception(SHORT_LINK_TIME_IS_NULL);
+ }
+ } else {
+ // 永久生效,清空时间
+ updateReqVO.setExpireTime(null);
+ }
+
+ Long tenantId = TenantContextHolder.getRequiredTenantId();
+ String domain = redisTemplate.opsForValue().get(SHORT_LINK_DOMAIN + ":" + tenantId);
+ if (StrUtil.isBlank(domain)) {
+ domain = this.domain;
+ }
+ // 更新
+ ShortLinkDO updateObj = BeanUtils.toBean(updateReqVO, ShortLinkDO.class);
+ updateObj.setDomain(domain);
+ shortLinkMapper.updateById(updateObj);
+ if (old != null && StrUtil.isNotBlank(old.getShortCode())) {
+ redisTemplate.delete(SHORT_LINK_CODE_CACHE + ":" + tenantId + ":" + old.getShortCode());
+ }
+ if (StrUtil.isNotBlank(updateObj.getShortCode())) {
+ redisTemplate.delete(SHORT_LINK_CODE_CACHE + ":" + tenantId + ":" + updateObj.getShortCode());
+ }
+ }
+
+ /**
+ * 删除短链
+ * - 校验存在后删除记录
+ * - 清理短码缓存
+ */
+ @Override
+ public void deleteShortLink(Long id) {
+ // 校验存在
+ validateShortLinkExists(id);
+ ShortLinkDO old = shortLinkMapper.selectById(id);
+
+ // 删除
+ shortLinkMapper.deleteById(id);
+ Long tenantId = TenantContextHolder.getRequiredTenantId();
+ if (old != null && StrUtil.isNotBlank(old.getShortCode())) {
+ redisTemplate.delete(SHORT_LINK_CODE_CACHE + ":" + tenantId + ":" + old.getShortCode());
+ }
+ }
+
+ /**
+ * 根据编号获取短链(按当前租户上下文)
+ */
+ @Override
+ public ShortLinkDO getShortLink(Long id) {
+ return shortLinkMapper.selectById(id);
+ }
+
+ /**
+ * 分页查询短链(按当前租户上下文)
+ */
+ @Override
+ public PageResult getShortLinkPage(ShortLinkPageReqVO pageReqVO) {
+ return shortLinkMapper.selectPage(pageReqVO);
+ }
+
+ /**
+ * 通过短码获取短链(支持无租户头访问)
+ * - 先跨租户忽略过滤查询短码
+ * - 解析到租户后切换上下文
+ */
+ @Override
+ public ShortLinkDO getShortLinkByCode(String shortCode) {
+ return resolveShortLinkIgnoreTenant(shortCode);
+ }
+
+ /**
+ * 访问短链并返回原始 URL(记录点击与访问日志)
+ * - 基于短码跨租户解析并切换到对应租户上下文
+ * - 按租户维度计算缓存键,使用缓存/数据库获取短链
+ * - 校验状态与过期时间
+ * - 记录点击次数与最后访问时间
+ * - 简单频控并异步记录访问日志
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public String accessShortLink(String shortCode, String ip, String userAgent, String referer) {
+ ShortLinkDO shortLink = resolveShortLinkIgnoreTenant(shortCode);
+ if (shortLink == null) {
+ throw exception(SHORT_LINK_NOT_EXISTS);
+ }
+ Long tenantId = TenantContextHolder.getRequiredTenantId();
+ String cacheKey = SHORT_LINK_CODE_CACHE + ":" + tenantId + ":" + shortCode;
+
+ String cached = redisTemplate.opsForValue().get(cacheKey);
+ ShortLinkDO shortLinkCached;
+ if (StrUtil.isNotBlank(cached)) {
+ try {
+ ObjectMapper mapper = new ObjectMapper();
+ shortLinkCached = mapper.readValue(cached, ShortLinkDO.class);
+ } catch (Exception e) {
+ shortLinkCached = shortLinkMapper.selectByShortCode(shortCode);
+ }
+ } else {
+ shortLinkCached = shortLinkMapper.selectByShortCode(shortCode);
+ if (shortLinkCached != null) {
+ try {
+ ObjectMapper mapper = new ObjectMapper();
+ redisTemplate.opsForValue().set(cacheKey, mapper.writeValueAsString(shortLinkCached));
+ } catch (Exception ignored) {
+ }
+ }
+ }
+ if (shortLinkCached == null) {
+ shortLinkCached = shortLink;
+ }
+ ShortLinkDO effective = shortLinkCached;
+ if (!shortLink.getStatus()) {
+ throw exception(SHORT_LINK_DISABLED);
+ }
+ if (effective.getExpireTime() != null && effective.getExpireTime().isBefore(LocalDateTime.now())) {
+ throw exception(SHORT_LINK_EXPIRED);
+ }
+
+ effective.setClickCount(effective.getClickCount() + 1);
+ effective.setLastClickTime(LocalDateTime.now());
+ shortLinkMapper.updateById(effective);
+
+ String normIp = normalizeIp(ip);
+ String rateKey = SHORT_LINK_ACCESS_RATE + ":" + tenantId + ":" + shortCode + ":" + (normIp == null ? "" : normIp);
+ Boolean allowed = redisTemplate.opsForValue().setIfAbsent(rateKey, "1", 5, TimeUnit.SECONDS);
+ if (Boolean.TRUE.equals(allowed)) {
+ ShortLinkAccessLogDO accessLog = ShortLinkAccessLogDO.builder()
+ .shortLinkId(shortLink.getId())
+ .shortCode(shortCode)
+ .ip(normIp)
+ .userAgent(userAgent)
+ .referer(referer)
+ .accessTime(LocalDateTime.now())
+ .build();
+ shortLinkAccessLogMapper.insert(accessLog);
+ }
+
+ return shortLink.getOriginalUrl();
+ }
+
+ /**
+ * 基于短码跨租户解析:
+ * 1. 暂时忽略租户过滤查询短码
+ * 2. 查到后切换到对应租户上下文
+ */
+ private ShortLinkDO resolveShortLinkIgnoreTenant(String shortCode) {
+ ShortLinkDO shortLink;
+ try {
+ TenantContextHolder.setIgnore(true);
+ shortLink = shortLinkMapper.selectByShortCode(shortCode);
+ } finally {
+ TenantContextHolder.setIgnore(false);
+ }
+ if (shortLink != null && shortLink.getTenantId() != null) {
+ TenantContextHolder.setTenantId(shortLink.getTenantId());
+ }
+ return shortLink;
+ }
+
+ /**
+ * 分页查询短链访问日志(按当前租户上下文)
+ */
+ @Override
+ public PageResult getShortLinkAccessLogPage(ShortLinkAccessLogPageReqVO pageReqVO) {
+ PageResult pageResult = shortLinkAccessLogMapper.selectPage(pageReqVO);
+ List respList = new ArrayList<>(pageResult.getList().size());
+ for (ShortLinkAccessLogDO log : pageResult.getList()) {
+ ShortLinkAccessLogRespVO vo = BeanUtils.toBean(log, ShortLinkAccessLogRespVO.class);
+ String country = null, province = null, city = null;
+ if (StrUtil.isNotBlank(log.getIp())) {
+ String nip = normalizeIp(log.getIp());
+ Area cur = IPUtils.getArea(nip);
+ for (int i = 0; i < Byte.MAX_VALUE && cur != null; i++) {
+ if (cur.getType() != null) {
+ if (AreaTypeEnum.COUNTRY.getType().equals(cur.getType()) && country == null) {
+ country = cur.getName();
+ } else if (AreaTypeEnum.PROVINCE.getType().equals(cur.getType()) && province == null) {
+ province = cur.getName();
+ } else if (AreaTypeEnum.CITY.getType().equals(cur.getType()) && city == null) {
+ city = cur.getName();
+ }
+ }
+ cur = cur.getParent();
+ }
+ }
+ vo.setCountry(country);
+ vo.setProvince(province);
+ vo.setCity(city);
+ respList.add(vo);
+ }
+ return new PageResult<>(respList, pageResult.getTotal());
+ }
+
+ /**
+ * 正常化 IP 地址,将 IPv6 转换成 IPv4
+ */
+ private String normalizeIp(String ip) {
+ if (StrUtil.isBlank(ip)) {
+ return ip;
+ }
+ String v = ip.trim();
+ if ("::1".equals(v) || "0:0:0:0:0:0:0:1".equals(v)) {
+ return "127.0.0.1";
+ }
+ Matcher m = Pattern.compile("(\\d{1,3}(?:\\.\\d{1,3}){3})").matcher(v);
+ if (m.find()) {
+ return m.group(1);
+ }
+ return v;
+ }
+
+ // ==================== 统计相关方法实现 ====================
+
+
+ /**
+ * 概览统计:总短链数、总点击量、活跃短链、平均点击率、区间新增与点击数
+ */
+ @Override
+ public ShortLinkOverviewStatsRespVO getShortLinkOverviewStats(LocalDateTime start, LocalDateTime end) {
+ Long totalShortLinks = shortLinkMapper.selectTotalCount();
+ Long totalClicks = shortLinkMapper.selectTotalClickCount();
+ Long activeShortLinks = shortLinkMapper.selectActiveCount();
+
+ LocalDateTime s = start != null ? start : LocalDate.now().atStartOfDay();
+ LocalDateTime e = end != null ? end : LocalDateTime.now();
+ Long newLinks = shortLinkMapper.selectCountByCreateTimeBetween(s, e);
+ Long clicks = shortLinkAccessLogMapper.selectCountByAccessTimeBetween(s, e);
+
+ return ShortLinkOverviewStatsRespVO.builder()
+ .totalLinks(totalShortLinks != null ? totalShortLinks : 0L)
+ .totalClicks(totalClicks != null ? totalClicks : 0L)
+ .activeLinks(activeShortLinks != null ? activeShortLinks : 0L)
+ .avgClickRate(totalShortLinks != null && totalShortLinks > 0 ? (double) (totalClicks != null ? totalClicks : 0L) / totalShortLinks * 100 : 0.0)
+ .todayNewLinks(newLinks != null ? newLinks : 0L)
+ .todayClicks(clicks != null ? clicks : 0L)
+ .build();
+ }
+
+ /**
+ * 趋势统计:按天计算新增与点击趋势
+ */
+ @Override
+ public List getShortLinkTrendStats(Integer days) {
+ List trendDataList = new ArrayList<>();
+
+ LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0).withNano(0);
+ for (int i = days - 1; i >= 0; i--) {
+ LocalDateTime dayStart = todayStart.minusDays(i);
+ LocalDateTime dayEnd = dayStart.plusDays(1);
+ Long createCount = shortLinkMapper.selectCountByCreateTimeBetween(dayStart, dayEnd);
+ Long clickCount = shortLinkAccessLogMapper.selectCountByAccessTimeBetween(dayStart, dayEnd);
+ trendDataList.add(ShortLinkTrendStatsRespVO.TrendData.builder()
+ .date(dayStart.toLocalDate())
+ .createCount(createCount != null ? createCount : 0L)
+ .clickCount(clickCount != null ? clickCount : 0L)
+ .build());
+ }
+
+ List result = new ArrayList<>();
+ result.add(ShortLinkTrendStatsRespVO.builder().trendData(trendDataList).build());
+
+ return result;
+ }
+
+ /**
+ * 状态分布统计:启用/禁用占比
+ */
+ @Override
+ public ShortLinkStatusStatsRespVO getShortLinkStatusStats() {
+ Long enabledCount = shortLinkMapper.selectEnabledCount();
+ Long disabledCount = shortLinkMapper.selectDisabledCount();
+ Long totalCount = enabledCount + disabledCount;
+
+ List statusList = new ArrayList<>();
+
+ if (totalCount > 0) {
+ statusList.add(ShortLinkStatusStatsRespVO.StatusData.builder().statusName("启用").statusValue(1).count(enabledCount).percentage(enabledCount * 100.0 / totalCount).build());
+
+ statusList.add(ShortLinkStatusStatsRespVO.StatusData.builder().statusName("禁用").statusValue(0).count(disabledCount).percentage(disabledCount * 100.0 / totalCount).build());
+ }
+
+ return ShortLinkStatusStatsRespVO.builder().statusData(statusList).build();
+ }
+
+ /**
+ * 地域统计:基于访问日志 IP 解析省/市维度占比
+ */
+ @Override
+ public ShortLinkRegionStatsRespVO getShortLinkRegionStats(LocalDateTime start, LocalDateTime end) {
+ end = end != null ? end : LocalDateTime.now();
+ start = start != null ? start : end.minusDays(30);
+ List logs = shortLinkAccessLogMapper.selectListByAccessTimeBetween(start, end);
+
+ java.util.Map counter = new java.util.HashMap<>();
+ long total = 0L;
+ for (ShortLinkAccessLogDO log : logs) {
+ String ip = normalizeIp(log.getIp());
+ if (StrUtil.isBlank(ip)) {
+ continue;
+ }
+ Area area = IPUtils.getArea(ip);
+ if (area == null) {
+ continue;
+ }
+ String province = null;
+ String city = null;
+ Area cur = area;
+ for (int i = 0; i < Byte.MAX_VALUE && cur != null; i++) {
+ if (cur.getType() != null) {
+ if (AreaTypeEnum.PROVINCE.getType().equals(cur.getType()) && province == null) {
+ province = cur.getName();
+ } else if (AreaTypeEnum.CITY.getType().equals(cur.getType()) && city == null) {
+ city = cur.getName();
+ }
+ }
+ cur = cur.getParent();
+ }
+ String key = (province != null ? province : "未知省份") + "|" + (city != null ? city : "未知城市");
+ counter.put(key, counter.getOrDefault(key, 0L) + 1);
+ total++;
+ }
+
+ List regionList = new ArrayList<>();
+ for (Map.Entry e : counter.entrySet()) {
+ String[] parts = e.getKey().split("\\|");
+ long count = e.getValue();
+ regionList.add(ShortLinkRegionStatsRespVO.RegionData.builder()
+ .province(parts[0])
+ .city(parts.length > 1 ? parts[1] : "")
+ .visitCount(count)
+ .percentage(total > 0 ? count * 100.0 / total : 0.0)
+ .build());
+ }
+ return ShortLinkRegionStatsRespVO.builder().regionData(regionList).build();
+ }
+
+ /**
+ * 设备统计:简单 UA 解析为 PC/Mobile/Tablet
+ */
+ @Override
+ public ShortLinkDeviceStatsRespVO getShortLinkDeviceStats(LocalDateTime start, LocalDateTime end) {
+ LocalDateTime e = end != null ? end : LocalDateTime.now();
+ LocalDateTime s = start != null ? start : e.minusDays(30);
+ List logs = shortLinkAccessLogMapper.selectListByAccessTimeBetween(s, e);
+
+ long total = logs.size();
+ long pc = 0, mobile = 0, tablet = 0;
+
+ for (ShortLinkAccessLogDO log : logs) {
+ String ua = log.getUserAgent();
+ String u = ua == null ? "" : ua.toLowerCase();
+ if (u.contains("ipad") || u.contains("tablet")) {
+ tablet++;
+ } else if (u.contains("android") || u.contains("iphone") || u.contains("mobile")) {
+ mobile++;
+ } else {
+ pc++;
+ }
+ }
+
+ List deviceList = new ArrayList<>();
+ deviceList.add(ShortLinkDeviceStatsRespVO.DeviceData.builder().deviceType("PC").deviceName("PC").visitCount(pc).percentage(total > 0 ? pc * 100.0 / total : 0.0).build());
+ deviceList.add(ShortLinkDeviceStatsRespVO.DeviceData.builder().deviceType("Mobile").deviceName("Mobile").visitCount(mobile).percentage(total > 0 ? mobile * 100.0 / total : 0.0).build());
+ deviceList.add(ShortLinkDeviceStatsRespVO.DeviceData.builder().deviceType("Tablet").deviceName("Tablet").visitCount(tablet).percentage(total > 0 ? tablet * 100.0 / total : 0.0).build());
+
+ return ShortLinkDeviceStatsRespVO.builder().deviceData(deviceList).build();
+ }
+
+ /**
+ * 时段统计:当日 24 小时访问量分布
+ */
+ @Override
+ public ShortLinkHourStatsRespVO getShortLinkHourStats(LocalDate date) {
+ List hourList = new ArrayList<>();
+ LocalDateTime startOfToday = (date != null ? date : LocalDate.now()).atStartOfDay();
+ LocalDateTime endOfToday = startOfToday.plusDays(1);
+ Long totalToday = shortLinkAccessLogMapper.selectCountByAccessTimeBetween(startOfToday, endOfToday);
+ long total = totalToday != null ? totalToday : 0L;
+
+ for (int hour = 0; hour < 24; hour++) {
+ LocalDateTime hStart = startOfToday.plusHours(hour);
+ LocalDateTime hEnd = hStart.plusHours(1);
+ Long count = shortLinkAccessLogMapper.selectCountByAccessTimeBetween(hStart, hEnd);
+ long accessCount = count != null ? count : 0L;
+ String timeDescription = String.format("%02d:00-%02d:59", hour, hour);
+ hourList.add(ShortLinkHourStatsRespVO.HourData.builder()
+ .hour(hour)
+ .hourRange(timeDescription)
+ .visitCount(accessCount)
+ .percentage(total > 0 ? accessCount * 100.0 / total : 0.0)
+ .build());
+ }
+
+ return ShortLinkHourStatsRespVO.builder().hourData(hourList).build();
+ }
+
+
+ /**
+ * 批量删除短链
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void deleteShortLinks(Long[] ids) {
+ // 校验存在
+ for (Long id : ids) {
+ validateShortLinkExists(id);
+ }
+
+ // 批量删除
+ shortLinkMapper.deleteBatchIds(Arrays.asList(ids));
+ }
+
+ /**
+ * 更新短链状态(启用/禁用)
+ */
+ @Override
+ public void updateShortLinkStatus(Long id, Integer status) {
+ // 校验存在
+ validateShortLinkExists(id);
+
+ // 更新状态
+ ShortLinkDO updateObj = new ShortLinkDO();
+ updateObj.setId(id);
+ updateObj.setStatus(status == 1);
+ shortLinkMapper.updateById(updateObj);
+ }
+
+ /**
+ * 更新当前租户的短链主域名(写入 Redis)
+ */
+ @Override
+ public void updateShortLinkDomain(String newDomain) {
+ Long tenantId = TenantContextHolder.getRequiredTenantId();
+ redisTemplate.opsForValue().set(SHORT_LINK_DOMAIN + ":" + tenantId, newDomain);
+ }
+
+ /**
+ * 获取当前租户短链主域名(Redis 优先,空则回退到配置项)
+ */
+ @Override
+ public String getShortLinkDomain() {
+ Long tenantId = TenantContextHolder.getRequiredTenantId();
+ String domain = redisTemplate.opsForValue().get(SHORT_LINK_DOMAIN + ":" + tenantId);
+ if (StrUtil.isBlank(domain)) {
+ domain = this.domain;
+ }
+ return domain;
+ }
+
+ /**
+ * 生成唯一的短码
+ *
+ * @param originalUrl 原始URL
+ * @param customShortCode 自定义短码
+ * @return 短码
+ */
+ private String generateUniqueShortCode(String originalUrl, String customShortCode) {
+ // 如果指定了自定义短码,先检查是否可用
+ if (StrUtil.isNotBlank(customShortCode)) {
+ if (!ShortCodeUtils.isValidShortCode(customShortCode)) {
+ throw exception(SHORT_LINK_CODE_FORMAT_ERROR);
+ }
+
+ ShortLinkDO existingShortLink = shortLinkMapper.selectByShortCode(customShortCode);
+ if (existingShortLink != null) {
+ throw exception(SHORT_LINK_CODE_IS_EXIST);
+ }
+
+ return customShortCode;
+ }
+
+ // 自动生成短码,最多尝试10次避免无限循环
+ for (int i = 0; i < 10; i++) {
+ String shortCode;
+ if (i < 5) {
+ // 前5次尝试使用MD5算法(相同URL生成相同短码)
+ shortCode = ShortCodeUtils.generateByMd5(originalUrl + i);
+ } else {
+ // 后5次使用随机算法
+ shortCode = ShortCodeUtils.generateRandom();
+ }
+
+ // 检查短码是否已存在
+ ShortLinkDO existingShortLink = shortLinkMapper.selectByShortCode(shortCode);
+ if (existingShortLink == null) {
+ return shortCode;
+ }
+ }
+
+ throw exception(SHORT_LINK_CODE_GENERATE_ERROR);
+ }
+
+ /**
+ * 校验短链是否存在
+ *
+ * @param id 短链编号
+ */
+ private void validateShortLinkExists(Long id) {
+ if (shortLinkMapper.selectById(id) == null) {
+ throw exception(SHORT_LINK_NOT_EXISTS);
+ }
+ }
+
+}
diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/util/shrotlink/ShortCodeUtils.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/util/shrotlink/ShortCodeUtils.java
new file mode 100644
index 000000000..d04c48eb5
--- /dev/null
+++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/util/shrotlink/ShortCodeUtils.java
@@ -0,0 +1,180 @@
+package cn.iocoder.yudao.module.system.util.shrotlink;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * 短码生成工具类
+ * 提供多种短码生成策略
+ *
+ * @author 巴蜀科技
+ */
+public class ShortCodeUtils {
+
+ /**
+ * Base62 字符集(包含数字、小写字母、大写字母)
+ */
+ private static final String BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+ /**
+ * 默认短码长度
+ */
+ private static final int DEFAULT_SHORT_CODE_LENGTH = 6;
+
+ /**
+ * 基于URL的MD5哈希生成短码
+ * 优点:相同URL生成相同短码,节省存储空间
+ * 缺点:可能存在哈希冲突
+ *
+ * @param originalUrl 原始URL
+ * @param length 短码长度
+ * @return 短码
+ */
+ public static String generateByMd5(String originalUrl, int length) {
+ if (StrUtil.isBlank(originalUrl)) {
+ throw new IllegalArgumentException("原始URL不能为空");
+ }
+ if (length <= 0 || length > 32) {
+ throw new IllegalArgumentException("短码长度必须在1-32之间");
+ }
+
+ String md5 = DigestUtil.md5Hex(originalUrl);
+ return convertToBase62(md5, length);
+ }
+
+ /**
+ * 基于URL的MD5哈希生成短码(默认长度)
+ *
+ * @param originalUrl 原始URL
+ * @return 短码
+ */
+ public static String generateByMd5(String originalUrl) {
+ return generateByMd5(originalUrl, DEFAULT_SHORT_CODE_LENGTH);
+ }
+
+ /**
+ * 随机生成短码
+ * 优点:生成速度快,冲突概率低
+ * 缺点:相同URL会生成不同短码
+ *
+ * @param length 短码长度
+ * @return 短码
+ */
+ public static String generateRandom(int length) {
+ if (length <= 0 || length > 32) {
+ throw new IllegalArgumentException("短码长度必须在1-32之间");
+ }
+
+ StringBuilder shortCode = new StringBuilder();
+ ThreadLocalRandom random = ThreadLocalRandom.current();
+
+ for (int i = 0; i < length; i++) {
+ int index = random.nextInt(BASE62_CHARS.length());
+ shortCode.append(BASE62_CHARS.charAt(index));
+ }
+
+ return shortCode.toString();
+ }
+
+ /**
+ * 随机生成短码(默认长度)
+ *
+ * @return 短码
+ */
+ public static String generateRandom() {
+ return generateRandom(DEFAULT_SHORT_CODE_LENGTH);
+ }
+
+ /**
+ * 基于雪花算法ID生成短码
+ * 优点:全局唯一,性能高
+ * 缺点:需要雪花算法支持
+ *
+ * @param snowflakeId 雪花算法生成的ID
+ * @param length 短码长度
+ * @return 短码
+ */
+ public static String generateBySnowflakeId(long snowflakeId, int length) {
+ if (length <= 0 || length > 32) {
+ throw new IllegalArgumentException("短码长度必须在1-32之间");
+ }
+
+ return convertToBase62(String.valueOf(snowflakeId), length);
+ }
+
+ /**
+ * 基于雪花算法ID生成短码(默认长度)
+ *
+ * @param snowflakeId 雪花算法生成的ID
+ * @return 短码
+ */
+ public static String generateBySnowflakeId(long snowflakeId) {
+ return generateBySnowflakeId(snowflakeId, DEFAULT_SHORT_CODE_LENGTH);
+ }
+
+ /**
+ * 验证短码格式是否正确
+ *
+ * @param shortCode 短码
+ * @return 是否有效
+ */
+ public static boolean isValidShortCode(String shortCode) {
+ if (StrUtil.isBlank(shortCode)) {
+ return false;
+ }
+
+ // 检查长度
+ if (shortCode.length() > 32) {
+ return false;
+ }
+
+ // 检查字符是否都在Base62字符集中
+ for (char c : shortCode.toCharArray()) {
+ if (BASE62_CHARS.indexOf(c) == -1) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * 将字符串转换为Base62编码的短码
+ *
+ * @param input 输入字符串
+ * @param length 目标长度
+ * @return Base62编码的短码
+ */
+ private static String convertToBase62(String input, int length) {
+ StringBuilder result = new StringBuilder();
+
+ // 使用输入字符串的哈希值作为种子
+ int seed = Math.abs(input.hashCode());
+
+ for (int i = 0; i < length; i++) {
+ int index = seed % BASE62_CHARS.length();
+ result.append(BASE62_CHARS.charAt(index));
+ seed = seed / BASE62_CHARS.length() + i * 31; // 添加位置因子避免重复
+ }
+
+ return result.toString();
+ }
+
+ /**
+ * 生成带前缀的短码
+ *
+ * @param prefix 前缀
+ * @param length 短码部分长度
+ * @return 带前缀的短码
+ */
+ public static String generateWithPrefix(String prefix, int length) {
+ if (StrUtil.isBlank(prefix)) {
+ throw new IllegalArgumentException("前缀不能为空");
+ }
+
+ return prefix + generateRandom(length);
+ }
+
+}
\ No newline at end of file
diff --git a/yudao-module-system/yudao-module-system-server/src/main/resources/application-dev.yaml b/yudao-module-system/yudao-module-system-server/src/main/resources/application-dev.yaml
index 649cc6ec9..9c27e633b 100644
--- a/yudao-module-system/yudao-module-system-server/src/main/resources/application-dev.yaml
+++ b/yudao-module-system/yudao-module-system-server/src/main/resources/application-dev.yaml
@@ -159,7 +159,10 @@ wx:
# 芋道配置项,设置当前项目所有自定义的配置
yudao:
demo: true # 开启演示模式
-
+ short-link:
+ # 该短链只是测试用,到你们正式使用时,可以将你们的域名通过Nginx映射到该接口
+ # 比如你们使用的域名是yudao.com,那么你可以通过配置域名a.yudao.com 映射到该接口,然后通过a.yudao.com/{shortCode}访问
+ domain: http://localhost:48080/app-api/system/short-link
justauth:
enabled: true
type:
diff --git a/yudao-module-system/yudao-module-system-server/src/main/resources/application-local.yaml b/yudao-module-system/yudao-module-system-server/src/main/resources/application-local.yaml
index a923b34b4..24f0f5bfa 100644
--- a/yudao-module-system/yudao-module-system-server/src/main/resources/application-local.yaml
+++ b/yudao-module-system/yudao-module-system-server/src/main/resources/application-local.yaml
@@ -188,7 +188,10 @@ yudao:
env-version: develop # 小程序版本: 正式版为 "release";体验版为 "trial";开发版为 "develop"
wxa-subscribe-message:
miniprogram-state: developer # 跳转小程序类型:开发版为 “developer”;体验版为 “trial”为;正式版为 “formal”
-
+ short-link:
+ # 该短链只是测试用,到你们正式使用时,可以将你们的域名通过Nginx映射到该接口
+ # 比如你们使用的域名是yudao.com,那么你可以通过配置域名a.yudao.com 映射到该接口,然后通过a.yudao.com/{shortCode}访问
+ domain: http://localhost:48080/app-api/system/short-link
justauth:
enabled: true
type:
diff --git a/yudao-server/src/main/resources/application-dev.yaml b/yudao-server/src/main/resources/application-dev.yaml
index f6c9b7c44..dc8a9d6e2 100644
--- a/yudao-server/src/main/resources/application-dev.yaml
+++ b/yudao-server/src/main/resources/application-dev.yaml
@@ -154,7 +154,8 @@ yudao:
transfer-notify-url: https://yunai.natapp1.cc/admin-api/pay/notify/transfer # 支付渠道的【转账】回调地址
demo: false # 开启演示模式
tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
-
+ short-link:
+ domain: http://localhost:48080
justauth:
enabled: true
type:
diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml
index 737608859..a539a9a60 100644
--- a/yudao-server/src/main/resources/application-local.yaml
+++ b/yudao-server/src/main/resources/application-local.yaml
@@ -46,27 +46,20 @@ spring:
primary: master
datasource:
master:
- url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
- # url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=true&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true # MySQL Connector/J 5.X 连接的示例
- # url: jdbc:postgresql://127.0.0.1:5432/ruoyi-vue-pro # PostgreSQL 连接的示例
- # url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例
- # url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=ruoyi-vue-pro;SelectMethod=cursor;encrypt=false;rewriteBatchedStatements=true;useUnicode=true;characterEncoding=utf-8 # SQLServer 连接的示例
- # url: jdbc:dm://127.0.0.1:5236?schema=RUOYI_VUE_PRO # DM 连接的示例
- # url: jdbc:kingbase8://127.0.0.1:54321/test # 人大金仓 KingbaseES 连接的示例
- # url: jdbc:postgresql://127.0.0.1:5432/postgres # OpenGauss 连接的示例
+ url: jdbc:mysql://192.168.67.11:3306/ruoyi_vue_pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: root
- password: 123456
+ password: SDC00hlQxG3j4hH
# username: sa # SQL Server 连接的示例
# password: Yudao@2024 # SQL Server 连接的示例
# username: SYSDBA # DM 连接的示例
# password: SYSDBA001 # DM 连接的示例
# username: root # OpenGauss 连接的示例
# password: Yudao@2024 # OpenGauss 连接的示例
- slave: # 模拟从库,可根据自己需要修改
- lazy: true # 开启懒加载,保证启动速度
- url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true
- username: root
- password: 123456
+# slave: # 模拟从库,可根据自己需要修改
+# lazy: true # 开启懒加载,保证启动速度
+# url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true
+# username: root
+# password: 123456
# tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!)
# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro
# driver-class-name: com.taosdata.jdbc.rs.RestfulDriver
@@ -78,10 +71,9 @@ spring:
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
data:
redis:
- host: 127.0.0.1 # 地址
+ host: 192.168.67.11 # 地址
port: 6379 # 端口
- database: 0 # 数据库索引
- # password: dev # 密码,建议生产环境开启
+ database: 4 # 数据库索引
--- #################### 定时任务相关配置 ####################
@@ -216,7 +208,8 @@ yudao:
wxa-subscribe-message:
miniprogram-state: developer # 跳转小程序类型:开发版为 “developer”;体验版为 “trial”为;正式版为 “formal”
tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
-
+ short-link:
+ domain: http://localhost:48080
justauth:
enabled: true
type: