feat(shortlink): 新增短链管理功能

- 新增短链表 `system_short_link` 及其访问记录表 `system_short_link_access_log`
- 添加短链相关菜单权限配置,包括新增、修改、删除、查询及统计页面
- 定义短链控制器请求与响应 VO 类,支持分页查询与统计数据展示
- 增加短链设备类型、访问时段、地域分布等统计接口返回结构
- 在 ErrorCodeConstants 中增加短链相关错误码定义
- 提供短链创建、状态管理、过期时间设置等功能校验逻辑
- 支持自定义短码与系统生成短码两种模式
- 实现短链点击次数追踪与最后访问时间记录机制
pull/223/head
吴帅苹 2025-12-05 11:46:04 +08:00
parent f4ba70ec5a
commit 45bb4b5459
28 changed files with 2128 additions and 24 deletions

View File

@ -2327,6 +2327,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 (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 (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 (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; COMMIT;
-- ---------------------------- -- ----------------------------
@ -3931,6 +3938,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 (142, 'test01', '$2a$04$IaR0fGYtalIDURMMdcaD2.4JDWZ15ueQZwap9oPUuxkwSbL66vIRG', 'test01', '', NULL, '[]', '', '19021719925', 1, '', 0, '0:0:0:0:0:0:0:1', '2025-07-29 19:47:17', '1', '2025-07-09 21:07:10', '1', '2025-11-25 19:49:08', b'0', 1); 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 (142, 'test01', '$2a$04$IaR0fGYtalIDURMMdcaD2.4JDWZ15ueQZwap9oPUuxkwSbL66vIRG', 'test01', '', NULL, '[]', '', '19021719925', 1, '', 0, '0:0:0:0:0:0:0:1', '2025-07-29 19:47:17', '1', '2025-07-09 21:07:10', '1', '2025-11-25 19:49:08', b'0', 1);
COMMIT; 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 -- Table structure for yudao_demo01_contact
-- ---------------------------- -- ----------------------------

View File

@ -4,7 +4,7 @@ import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/** /**
* System * System
* * <p>
* system 使 1-002-000-000 * system 使 1-002-000-000
*/ */
public interface ErrorCodeConstants { public interface ErrorCodeConstants {
@ -49,7 +49,7 @@ public interface ErrorCodeConstants {
// ========== 部门模块 1-002-004-000 ========== // ========== 部门模块 1-002-004-000 ==========
ErrorCode DEPT_NAME_DUPLICATE = new ErrorCode(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_NOT_FOUND = new ErrorCode(1_002_004_002, "当前部门不存在");
ErrorCode DEPT_EXITS_CHILDREN = new ErrorCode(1_002_004_003, "存在子部门,无法删除"); ErrorCode DEPT_EXITS_CHILDREN = new ErrorCode(1_002_004_003, "存在子部门,无法删除");
ErrorCode DEPT_PARENT_ERROR = new ErrorCode(1_002_004_004, "不能设置自己为父部门"); ErrorCode DEPT_PARENT_ERROR = new ErrorCode(1_002_004_004, "不能设置自己为父部门");
@ -168,4 +168,13 @@ public interface ErrorCodeConstants {
// ========== 站内信发送 1-002-028-000 ========== // ========== 站内信发送 1-002-028-000 ==========
ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(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, "短链已过期");
} }

View File

@ -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<Long> createShortLink(@Valid @RequestBody ShortLinkCreateReqVO createReqVO) {
return success(shortLinkService.createShortLink(createReqVO));
}
@PutMapping("/domain")
@Operation(summary = "修改短链域名")
@PreAuthorize("@ss.hasPermission('system:short-link:update')")
public CommonResult<Boolean> updateShortLinkDomain(@RequestParam("newDomain") String newDomain) {
shortLinkService.updateShortLinkDomain(newDomain);
return success(true);
}
@GetMapping("/domain")
@Operation(summary = "获取短链域名")
@PreAuthorize("@ss.hasPermission('system:short-link:query')")
public CommonResult<String> getShortLinkDomain() {
return success(shortLinkService.getShortLinkDomain());
}
@PutMapping("/update")
@Operation(summary = "更新短链")
@PreAuthorize("@ss.hasPermission('system:short-link:update')")
public CommonResult<Boolean> 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<Boolean> 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<ShortLinkRespVO> 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<PageResult<ShortLinkRespVO>> getShortLinkPage(@Valid ShortLinkPageReqVO pageReqVO) {
PageResult<ShortLinkDO> 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<ShortLinkDO> 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<ShortLinkRespVO> 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<Boolean> deleteShortLinks(@RequestBody Long[] ids) {
shortLinkService.deleteShortLinks(ids);
return success(true);
}
@PutMapping("/update-status")
@Operation(summary = "修改短链状态")
@PreAuthorize("@ss.hasPermission('system:short-link:update')")
public CommonResult<Boolean> 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<ShortLinkOverviewStatsRespVO> 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<List<ShortLinkTrendStatsRespVO>> 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<ShortLinkStatusStatsRespVO> getShortLinkStatusStats() {
return success(shortLinkService.getShortLinkStatusStats());
}
@GetMapping("/statistics/region")
@Operation(summary = "获取短链地域访问统计")
@PreAuthorize("@ss.hasPermission('system:short-link:query')")
public CommonResult<ShortLinkRegionStatsRespVO> 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<ShortLinkDeviceStatsRespVO> 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<ShortLinkHourStatsRespVO> 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<PageResult<ShortLinkAccessLogRespVO>> 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<ShortLinkAccessLogRespVO> list = shortLinkService.getShortLinkAccessLogPage(pageReqVO).getList();
// 导出 Excel
ExcelUtils.write(response, "短链访问日志.xls", "数据", ShortLinkAccessLogRespVO.class, list);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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> 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;
}
}

View File

@ -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> 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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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> 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;
}
}

View File

@ -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;
}

View File

@ -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> 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;
}
}

View File

@ -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> 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;
}
}

View File

@ -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;
}

View File

@ -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<Boolean> 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<String> 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());
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<ShortLinkAccessLogDO> {
default PageResult<ShortLinkAccessLogDO> selectPage(ShortLinkAccessLogPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<ShortLinkAccessLogDO>()
.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<ShortLinkAccessLogDO>()
.between(ShortLinkAccessLogDO::getAccessTime, start, end));
}
/**
* 访访
*/
default java.util.List<ShortLinkAccessLogDO> selectListByAccessTimeBetween(java.time.LocalDateTime start, java.time.LocalDateTime end) {
return selectList(new LambdaQueryWrapperX<ShortLinkAccessLogDO>()
.between(ShortLinkAccessLogDO::getAccessTime, start, end));
}
}

View File

@ -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<ShortLinkDO> {
default PageResult<ShortLinkDO> selectPage(ShortLinkPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<ShortLinkDO>()
.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<ShortLinkDO>()
.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<ShortLinkDO>()
.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<ShortLinkDO>()
.between(ShortLinkDO::getCreateTime, start, end));
}
}

View File

@ -101,10 +101,34 @@ public interface RedisKeyConstants {
/** /**
* *
* * <p>
* KEY wxa_subscribe_template:{userType} * KEY wxa_subscribe_template:{userType}
* VALUE String, * VALUE String,
*/ */
String WXA_SUBSCRIBE_TEMPLATE = "wxa_subscribe_template"; String WXA_SUBSCRIBE_TEMPLATE = "wxa_subscribe_template";
/**
*
* <p>
* KEY : short_link_domain:{tenantId}
* VALUE String
*/
String SHORT_LINK_DOMAIN = "short_link_domain";
/**
*
* <p>
* KEY : short_link_code_cache:{code}
* VALUE String
*/
String SHORT_LINK_CODE_CACHE = "short_link_code_cache";
/**
* 访
* <p>
* KEY : short_link_access_rate:{code}
* VALUE String 访
*/
String SHORT_LINK_ACCESS_RATE = "short_link_access_rate";
} }

View File

@ -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<ShortLinkDO> 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<ShortLinkAccessLogRespVO> getShortLinkAccessLogPage(ShortLinkAccessLogPageReqVO pageReqVO);
/**
*
*
* @param start
* @param end
* @return
*/
ShortLinkOverviewStatsRespVO getShortLinkOverviewStats(LocalDateTime start, LocalDateTime end);
/**
*
*
* @param days
* @return
*/
List<ShortLinkTrendStatsRespVO> 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();
}

View File

@ -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<ShortLinkDO> 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<ShortLinkAccessLogRespVO> getShortLinkAccessLogPage(ShortLinkAccessLogPageReqVO pageReqVO) {
PageResult<ShortLinkAccessLogDO> pageResult = shortLinkAccessLogMapper.selectPage(pageReqVO);
List<ShortLinkAccessLogRespVO> 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<ShortLinkTrendStatsRespVO> getShortLinkTrendStats(Integer days) {
List<ShortLinkTrendStatsRespVO.TrendData> 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<ShortLinkTrendStatsRespVO> 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<ShortLinkStatusStatsRespVO.StatusData> 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<ShortLinkAccessLogDO> logs = shortLinkAccessLogMapper.selectListByAccessTimeBetween(start, end);
java.util.Map<String, Long> 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<ShortLinkRegionStatsRespVO.RegionData> regionList = new ArrayList<>();
for (Map.Entry<String, Long> 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<ShortLinkAccessLogDO> 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<ShortLinkDeviceStatsRespVO.DeviceData> 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<ShortLinkHourStatsRespVO.HourData> 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);
}
}
}

View File

@ -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;
/**
* URLMD5
* 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);
}
/**
* URLMD5
*
* @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);
}
}

View File

@ -157,7 +157,10 @@ wx:
# 芋道配置项,设置当前项目所有自定义的配置 # 芋道配置项,设置当前项目所有自定义的配置
yudao: yudao:
demo: true # 开启演示模式 demo: true # 开启演示模式
short-link:
# 该短链只是测试用到你们正式使用时可以将你们的域名通过Nginx映射到该接口
# 比如你们使用的域名是yudao.com,那么你可以通过配置域名a.yudao.com 映射到该接口然后通过a.yudao.com/{shortCode}访问
domain: http://localhost:48080/app-api/system/short-link
justauth: justauth:
enabled: true enabled: true
type: type:

View File

@ -186,7 +186,10 @@ yudao:
env-version: develop # 小程序版本: 正式版为 "release";体验版为 "trial";开发版为 "develop" env-version: develop # 小程序版本: 正式版为 "release";体验版为 "trial";开发版为 "develop"
wxa-subscribe-message: wxa-subscribe-message:
miniprogram-state: developer # 跳转小程序类型:开发版为 “developer”体验版为 “trial”为正式版为 “formal” 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: justauth:
enabled: true enabled: true
type: type:

View File

@ -152,7 +152,8 @@ yudao:
transfer-notify-url: https://yunai.natapp1.cc/admin-api/pay/notify/transfer # 支付渠道的【转账】回调地址 transfer-notify-url: https://yunai.natapp1.cc/admin-api/pay/notify/transfer # 支付渠道的【转账】回调地址
demo: false # 开启演示模式 demo: false # 开启演示模式
tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc 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: justauth:
enabled: true enabled: true
type: type:

View File

@ -46,27 +46,20 @@ spring:
primary: master primary: master
datasource: datasource:
master: 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://192.168.67.11: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 连接的示例
username: root username: root
password: 123456 password: SDC00hlQxG3j4hH
# username: sa # SQL Server 连接的示例 # username: sa # SQL Server 连接的示例
# password: Yudao@2024 # SQL Server 连接的示例 # password: Yudao@2024 # SQL Server 连接的示例
# username: SYSDBA # DM 连接的示例 # username: SYSDBA # DM 连接的示例
# password: SYSDBA001 # DM 连接的示例 # password: SYSDBA001 # DM 连接的示例
# username: root # OpenGauss 连接的示例 # username: root # OpenGauss 连接的示例
# password: Yudao@2024 # OpenGauss 连接的示例 # password: Yudao@2024 # OpenGauss 连接的示例
slave: # 模拟从库,可根据自己需要修改 # slave: # 模拟从库,可根据自己需要修改
lazy: true # 开启懒加载,保证启动速度 # lazy: true # 开启懒加载,保证启动速度
url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=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 # username: root
password: 123456 # password: 123456
# tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!) # tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!)
# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro # url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro
# driver-class-name: com.taosdata.jdbc.rs.RestfulDriver # driver-class-name: com.taosdata.jdbc.rs.RestfulDriver
@ -78,10 +71,9 @@ spring:
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
data: data:
redis: redis:
host: 127.0.0.1 # 地址 host: 192.168.67.11 # 地址
port: 6379 # 端口 port: 6379 # 端口
database: 0 # 数据库索引 database: 4 # 数据库索引
# password: dev # 密码,建议生产环境开启
--- #################### 定时任务相关配置 #################### --- #################### 定时任务相关配置 ####################
@ -214,7 +206,8 @@ yudao:
wxa-subscribe-message: wxa-subscribe-message:
miniprogram-state: developer # 跳转小程序类型:开发版为 “developer”体验版为 “trial”为正式版为 “formal” miniprogram-state: developer # 跳转小程序类型:开发版为 “developer”体验版为 “trial”为正式版为 “formal”
tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc 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: justauth:
enabled: true enabled: true
type: type: