Jelajahi Sumber

init from phab

xielq 5 tahun lalu
induk
melakukan
06dc0411e8
35 mengubah file dengan 1784 tambahan dan 0 penghapusan
  1. 0 0
      README.md
  2. 5 0
      Readme.md
  3. 59 0
      build.gradle
  4. 2 0
      settings.gradle
  5. 6 0
      src/main/docker/Dockerfile
  6. 15 0
      src/main/java/com/uas/platform/click/ClickApplication.java
  7. 39 0
      src/main/java/com/uas/platform/click/api/v1/LogController.java
  8. 41 0
      src/main/java/com/uas/platform/click/api/v1/RedirectController.java
  9. 22 0
      src/main/java/com/uas/platform/click/api/v1/SecurityController.java
  10. 42 0
      src/main/java/com/uas/platform/click/api/v1/TemplateController.java
  11. 32 0
      src/main/java/com/uas/platform/click/api/v1/WrapController.java
  12. 21 0
      src/main/java/com/uas/platform/click/config/WebMvcConfig.java
  13. 139 0
      src/main/java/com/uas/platform/click/config/WebSecurityConfig.java
  14. 85 0
      src/main/java/com/uas/platform/click/entity/Template.java
  15. 130 0
      src/main/java/com/uas/platform/click/entity/UrlLog.java
  16. 105 0
      src/main/java/com/uas/platform/click/entity/UrlLogHistory.java
  17. 12 0
      src/main/java/com/uas/platform/click/repository/TemplateRepository.java
  18. 20 0
      src/main/java/com/uas/platform/click/repository/UrlLogHistoryRepository.java
  19. 20 0
      src/main/java/com/uas/platform/click/repository/UrlLogRepository.java
  20. 41 0
      src/main/java/com/uas/platform/click/service/TemplateService.java
  21. 131 0
      src/main/java/com/uas/platform/click/service/UrlLogService.java
  22. 19 0
      src/main/java/com/uas/platform/click/util/DateUtil.java
  23. 40 0
      src/main/java/com/uas/platform/click/util/IpUtil.java
  24. 25 0
      src/main/java/com/uas/platform/click/util/RequestUtil.java
  25. 29 0
      src/main/java/com/uas/platform/click/util/StringUtil.java
  26. 97 0
      src/main/java/com/uas/platform/click/web/ResponseWrap.java
  27. 27 0
      src/main/resources/application.yml
  28. 39 0
      src/main/resources/static/css/dashboard.css
  29. 87 0
      src/main/resources/static/css/login.css
  30. 134 0
      src/main/resources/static/dashboard.html
  31. 46 0
      src/main/resources/static/invalid.html
  32. 95 0
      src/main/resources/static/js/dashboard.js
  33. 69 0
      src/main/resources/static/js/log.js
  34. 78 0
      src/main/resources/static/log.html
  35. 32 0
      src/main/resources/static/login.html

+ 0 - 0
README.md


+ 5 - 0
Readme.md

@@ -0,0 +1,5 @@
+# 功能
+> 封装点击链接,支持多次点击的统计,单次点击的统计
+
+# 用法
+> 调用 https://click.ubtob.com/v1/wrap?url={url}&templateId={templateId} 接口,将 https://www.usoftmall.com 封装成代理链接 https://click.ubtob.com/v1/redirect?id=yyvobrnvlbxjyi8nvv0gzrn1mdpi2bju

+ 59 - 0
build.gradle

@@ -0,0 +1,59 @@
+group 'com.uas.platform'
+version '0.0.1'
+
+buildscript {
+    ext {
+        springBootVersion = '1.5.9.RELEASE'
+        dockerVersion = '0.12.0'
+        dockerRegistry = "10.10.100.200:5000"
+    }
+    repositories {
+        maven { url "http://maven.aliyun.com/nexus/content/groups/public/" }
+        maven { url "https://plugins.gradle.org/m2/" }
+        maven { url "https://repo.spring.io/libs-release" }
+        mavenCentral()
+        jcenter()
+    }
+    dependencies {
+        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
+        classpath "gradle.plugin.com.palantir.gradle.docker:gradle-docker:${dockerVersion}"
+    }
+}
+
+apply plugin: 'java'
+apply plugin: "com.palantir.docker"
+apply plugin: "org.springframework.boot"
+
+sourceCompatibility = 1.8
+
+repositories {
+    mavenLocal()
+    maven { url "http://repo.spring.io/libs-release" }
+    maven { url "http://maven.aliyun.com/nexus/content/groups/public/" }
+    mavenCentral()
+}
+
+dependencies {
+    compile "org.springframework.boot:spring-boot-starter-web"
+    testCompile "org.springframework.boot:spring-boot-starter-test"
+    compile 'org.springframework.boot:spring-boot-starter-data-jpa'
+    compile "org.springframework.boot:spring-boot-starter-security"
+    compile "org.springframework.session:spring-session"
+    compile 'mysql:mysql-connector-java:5.1.44'
+    compile "com.alibaba:fastjson:1.2.14"
+}
+
+jar {
+    baseName = project.name
+    version = ''
+}
+
+bootRun {
+    addResources = true
+}
+
+docker {
+    name "${dockerRegistry}/${project.name}:${project.version}"
+    dockerfile "${projectDir}/src/main/docker/Dockerfile"
+    files "${buildDir}/libs/${project.name}.jar"
+}.dependsOn build

+ 2 - 0
settings.gradle

@@ -0,0 +1,2 @@
+rootProject.name = 'click-service'
+

+ 6 - 0
src/main/docker/Dockerfile

@@ -0,0 +1,6 @@
+FROM hub.c.163.com/library/java:8-jre-alpine
+VOLUME /tmp # reate a temporary file on my host under "/var/lib/docker" and link it to the container under "/tmp".
+ADD click-service.jar app.jar
+RUN sh -c "touch /app.jar" #  "touch" the jar file so that it has a file modification time (Docker creates all container files in an "unmodified" state by default). This actually isn’t important for the simple app that we wrote, but any static content (e.g. "index.html") would require the file to have a modification time.
+ENV JAVA_OPTS=""
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Duser.timezone=GMT+08 -Djava.security.egd=file:/dev/./urandom -jar /app.jar --spring.profiles.active=prod"] # To reduce Tomcat startup time we added a system property pointing to "/dev/urandom" as a source of entropy.

+ 15 - 0
src/main/java/com/uas/platform/click/ClickApplication.java

@@ -0,0 +1,15 @@
+package com.uas.platform.click;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@SpringBootApplication
+public class ClickApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(ClickApplication.class);
+    }
+}

+ 39 - 0
src/main/java/com/uas/platform/click/api/v1/LogController.java

@@ -0,0 +1,39 @@
+package com.uas.platform.click.api.v1;
+
+import com.uas.platform.click.service.UrlLogService;
+import com.uas.platform.click.web.ResponseWrap;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@RestController
+@RequestMapping(path = "/v1/log")
+public class LogController {
+
+    @Autowired
+    private UrlLogService urlLogService;
+
+    /**
+     * 查找日志
+     *
+     * @param templateId
+     * @param onlyClicked
+     * @param onlyNotClicked
+     * @param pageable
+     * @return
+     */
+    @GetMapping
+    public ResponseEntity findByTemplate(String templateId, Boolean onlyClicked, Boolean onlyNotClicked,
+                                         @PageableDefault(size = 15, page = 0, sort = {"createDate"}, direction = Sort.Direction.DESC) Pageable pageable) {
+        return ResponseWrap.ok(urlLogService.findByCondition(templateId, (null != onlyClicked && onlyClicked.booleanValue()),
+                (null != onlyNotClicked && onlyNotClicked.booleanValue()), pageable));
+    }
+}

+ 41 - 0
src/main/java/com/uas/platform/click/api/v1/RedirectController.java

@@ -0,0 +1,41 @@
+package com.uas.platform.click.api.v1;
+
+import com.uas.platform.click.entity.UrlLog;
+import com.uas.platform.click.service.UrlLogService;
+import com.uas.platform.click.util.IpUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 点击链接记录点击事件并跳转
+ *
+ * Created by Pro1 on 2018/2/9.
+ */
+@Controller
+@RequestMapping(path = "/v1/redirect")
+public class RedirectController {
+
+    @Autowired
+    private UrlLogService urlLogService;
+
+    @GetMapping
+    public void redirect(@RequestParam(required = true) String id,
+                         HttpServletRequest request, HttpServletResponse response) throws IOException{
+        UrlLog log = urlLogService.findOne(id);
+        if (null != log && (null == log.getClickDate() || !log.isOnceRedirect())) {
+            log.setClickIp(IpUtil.getRequestIp(request));
+            log.setClickAgent(request.getHeader("user-agent"));
+            urlLogService.setClicked(log);
+            response.sendRedirect(log.getOriginUrl());
+        } else {
+            response.sendRedirect("/invalid");
+        }
+    }
+}

+ 22 - 0
src/main/java/com/uas/platform/click/api/v1/SecurityController.java

@@ -0,0 +1,22 @@
+package com.uas.platform.click.api.v1;
+
+import com.uas.platform.click.web.ResponseWrap;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.security.Principal;
+import java.util.Optional;
+
+/**
+ * Created by Pro1 on 2018/2/10.
+ */
+@RestController
+public class SecurityController {
+
+    @GetMapping(path = "/v1/principal")
+    public ResponseEntity getPrincipal(Principal principal) {
+        return ResponseWrap.ok(Optional.ofNullable(principal).orElse(null).getName());
+    }
+
+}

+ 42 - 0
src/main/java/com/uas/platform/click/api/v1/TemplateController.java

@@ -0,0 +1,42 @@
+package com.uas.platform.click.api.v1;
+
+import com.uas.platform.click.entity.Template;
+import com.uas.platform.click.service.TemplateService;
+import com.uas.platform.click.web.ResponseWrap;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@RestController
+@RequestMapping(path = "/v1/template")
+public class TemplateController {
+
+    @Autowired
+    private TemplateService templateService;
+
+    /**
+     * 保存模板设置
+     *
+     * @param template
+     * @return
+     */
+    @PostMapping
+    public ResponseEntity save(Template template) {
+        return ResponseWrap.ok(templateService.save(template));
+    }
+
+    @DeleteMapping
+    public ResponseEntity delete(@RequestParam(required = true) String id) {
+        templateService.delete(id);
+        return ResponseWrap.ok();
+    }
+
+    @GetMapping
+    public ResponseEntity findAll() {
+        return ResponseWrap.ok(templateService.findAll());
+    }
+
+}

+ 32 - 0
src/main/java/com/uas/platform/click/api/v1/WrapController.java

@@ -0,0 +1,32 @@
+package com.uas.platform.click.api.v1;
+
+import com.uas.platform.click.service.UrlLogService;
+import com.uas.platform.click.web.ResponseWrap;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@RestController
+@RequestMapping(path = "/v1/wrap")
+public class WrapController {
+
+    @Autowired
+    private UrlLogService urlLogService;
+
+    /**
+     * @param url 原链接
+     * @param templateId 模板
+     * @return
+     */
+    @GetMapping
+    public ResponseEntity wrapUrl(@RequestParam(required = true) String url, String templateId) {
+        return ResponseWrap.ok(urlLogService.save(templateId, url));
+    }
+
+}

+ 21 - 0
src/main/java/com/uas/platform/click/config/WebMvcConfig.java

@@ -0,0 +1,21 @@
+package com.uas.platform.click.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
+
+/**
+ * Created by Pro1 on 2017/3/22.
+ */
+@Configuration
+public class WebMvcConfig extends WebMvcConfigurerAdapter {
+
+    @Override
+    public void addViewControllers(ViewControllerRegistry registry) {
+        registry.addViewController("/dashboard").setViewName("/dashboard.html");
+        registry.addViewController("/dashboard/login").setViewName("/login.html");
+        registry.addViewController("/dashboard/log").setViewName("/log.html");
+        registry.addViewController("/invalid").setViewName("/invalid.html");
+    }
+
+}

+ 139 - 0
src/main/java/com/uas/platform/click/config/WebSecurityConfig.java

@@ -0,0 +1,139 @@
+package com.uas.platform.click.config;
+
+import com.uas.platform.click.util.RequestUtil;
+import com.uas.platform.click.web.ResponseWrap;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.MessageSource;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.i18n.LocaleContextHolder;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.builders.WebSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.util.matcher.AnyRequestMatcher;
+import org.springframework.session.ExpiringSession;
+import org.springframework.session.MapSessionRepository;
+import org.springframework.session.SessionRepository;
+import org.springframework.ui.ModelMap;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * Created by Pro1 on 2017/6/20.
+ */
+@Configuration
+@EnableWebSecurity
+public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
+
+    @Autowired
+    private MessageSource messageSource;
+
+    @Bean
+    public SessionRepository<ExpiringSession> sessionRepository() {
+        return new MapSessionRepository();
+    }
+
+    @Override
+    public void configure(WebSecurity web) throws Exception {
+        web.ignoring().antMatchers("/resources/**", "/static/**", "/public/**",
+                "/html/**", "/css/**", "/js/**", "**/*.css", "**/*.js");
+    }
+
+    @Override
+    public void configure(HttpSecurity http) throws Exception {
+        http.authorizeRequests()
+                .antMatchers("/dashboard/login", "/v1/redirect", "/v1/wrap")
+                .permitAll()
+                .anyRequest()
+                .authenticated()
+                .and()
+                .formLogin()
+                .loginProcessingUrl("/dashboard/login")
+                .successHandler(authenticationSuccessHandler())
+                .failureHandler(authenticationFailureHandler())
+                .and()
+                .exceptionHandling()
+                .defaultAuthenticationEntryPointFor(jsonAuthenticationEntryPoint(), AnyRequestMatcher.INSTANCE)
+                .and()
+                .logout()
+                .logoutUrl("/dashboard/logout")
+                .and()
+                .csrf()
+                .disable()
+                .sessionManagement()
+                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
+    }
+
+    /**
+     * 登录成功时
+     * @return
+     */
+    @Bean
+    public AuthenticationSuccessHandler authenticationSuccessHandler() {
+        return new AuthenticationSuccessHandler() {
+            @Override
+            public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
+                Object returnUrl = request.getSession().getAttribute(RETURN_URL);
+                if (null == returnUrl || returnUrl.toString().matches(".*\\/dashboard\\/login")) {
+                    returnUrl = "/dashboard";
+                }
+                if (RequestUtil.isAjax(request)) {
+                    ResponseWrap.ok(response, new ModelMap(RETURN_URL, returnUrl));
+                } else {
+                    response.sendRedirect(returnUrl.toString());
+                }
+            }
+        };
+    }
+
+    /**
+     * 登录失败时
+     * @return
+     */
+    @Bean
+    public AuthenticationFailureHandler authenticationFailureHandler() {
+        return new AuthenticationFailureHandler() {
+            public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
+                ResponseWrap.badRequest(response, messageSource.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", null, LocaleContextHolder.getLocale()));
+            }
+        };
+    }
+
+    final static String RETURN_URL = "returnUrl";
+
+    /**
+     * 身份信息验证失败时
+     * @return
+     */
+    @Bean
+    public AuthenticationEntryPoint jsonAuthenticationEntryPoint() {
+        return new AuthenticationEntryPoint() {
+            @Override
+            public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
+                if (RequestUtil.isAjax(request)) {
+                    ResponseWrap.badRequest(response, HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
+                } else {
+                    request.getSession().setAttribute(RETURN_URL, RequestUtil.getUri(request));
+                    response.sendRedirect("/dashboard/login");
+                }
+            }
+        };
+    }
+
+    @Autowired
+    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
+        auth.inMemoryAuthentication().withUser("admin").password("select").roles("ADMIN");
+    }
+}

+ 85 - 0
src/main/java/com/uas/platform/click/entity/Template.java

@@ -0,0 +1,85 @@
+package com.uas.platform.click.entity;
+
+import org.hibernate.annotations.GenericGenerator;
+import org.hibernate.validator.constraints.NotEmpty;
+
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import java.io.Serializable;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@Entity
+public class Template implements Serializable{
+
+    @Id
+    @GeneratedValue(generator = "templateGenerator")
+    @GenericGenerator(name = "templateGenerator", strategy = "guid")
+    private String id;
+    @NotEmpty
+    private String title;
+    private boolean onceStat;
+    private boolean onceRedirect;
+    private int totalUrlSize;
+    private int clickedUrlSize;
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getTitle() {
+        return title;
+    }
+
+    public void setTitle(String title) {
+        this.title = title;
+    }
+
+    /**
+     * 多次点击时,只统计一次
+     *
+     * @return
+     */
+    public boolean isOnceStat() {
+        return onceStat;
+    }
+
+    public void setOnceStat(boolean onceStat) {
+        this.onceStat = onceStat;
+    }
+
+    public int getTotalUrlSize() {
+        return totalUrlSize;
+    }
+
+    public void setTotalUrlSize(int totalUrlSize) {
+        this.totalUrlSize = totalUrlSize;
+    }
+
+    public int getClickedUrlSize() {
+        return clickedUrlSize;
+    }
+
+    /**
+     * 只允许点击、跳转一次
+     *
+     * @return
+     */
+    public boolean isOnceRedirect() {
+        return onceRedirect;
+    }
+
+    public void setOnceRedirect(boolean onceRedirect) {
+        this.onceRedirect = onceRedirect;
+    }
+
+    public void setClickedUrlSize(int clickedUrlSize) {
+        this.clickedUrlSize = clickedUrlSize;
+    }
+}

+ 130 - 0
src/main/java/com/uas/platform/click/entity/UrlLog.java

@@ -0,0 +1,130 @@
+package com.uas.platform.click.entity;
+
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@Entity
+public class UrlLog implements Serializable{
+
+    @Id
+    private String id;
+    private String templateId;
+    private String originUrl;
+    private String wrappedUrl;
+    private Date createDate;
+    private Date clickDate;
+    private boolean onceStat;
+    private boolean onceRedirect;
+    private String clickIp;
+    private String clickAgent;
+
+    public UrlLog() {
+        this.createDate = new Date();
+    }
+
+    public UrlLog(String templateId, String originUrl) {
+        this();
+        this.templateId = templateId;
+        this.originUrl = originUrl;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getTemplateId() {
+        return templateId;
+    }
+
+    public void setTemplateId(String templateId) {
+        this.templateId = templateId;
+    }
+
+    public String getOriginUrl() {
+        return originUrl;
+    }
+
+    public void setOriginUrl(String originUrl) {
+        this.originUrl = originUrl;
+    }
+
+    public String getWrappedUrl() {
+        return wrappedUrl;
+    }
+
+    public void setWrappedUrl(String wrappedUrl) {
+        this.wrappedUrl = wrappedUrl;
+    }
+
+    public Date getCreateDate() {
+        return createDate;
+    }
+
+    public void setCreateDate(Date createDate) {
+        this.createDate = createDate;
+    }
+
+    public Date getClickDate() {
+        return clickDate;
+    }
+
+    public void setClickDate(Date clickDate) {
+        this.clickDate = clickDate;
+    }
+
+    public boolean isOnceStat() {
+        return onceStat;
+    }
+
+    public void setOnceStat(boolean onceStat) {
+        this.onceStat = onceStat;
+    }
+
+    public boolean isOnceRedirect() {
+        return onceRedirect;
+    }
+
+    public void setOnceRedirect(boolean onceRedirect) {
+        this.onceRedirect = onceRedirect;
+    }
+
+    public String getClickIp() {
+        return clickIp;
+    }
+
+    public void setClickIp(String clickIp) {
+        this.clickIp = clickIp;
+    }
+
+    public String getClickAgent() {
+        return clickAgent;
+    }
+
+    public void setClickAgent(String clickAgent) {
+        this.clickAgent = clickAgent;
+    }
+
+    public UrlLogHistory toHistory() {
+        UrlLogHistory his = new UrlLogHistory();
+        his.setCreateDate(getCreateDate());
+        his.setClickDate(getClickDate());
+        his.setClickAgent(getClickAgent());
+        his.setOnceStat(isOnceStat());
+        his.setOnceRedirect(isOnceRedirect());
+        his.setClickIp(getClickIp());
+        his.setId(getId());
+        his.setOriginUrl(getOriginUrl());
+        his.setTemplateId(getTemplateId());
+        his.setWrappedUrl(getWrappedUrl());
+        return his;
+    }
+}

+ 105 - 0
src/main/java/com/uas/platform/click/entity/UrlLogHistory.java

@@ -0,0 +1,105 @@
+package com.uas.platform.click.entity;
+
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@Entity
+public class UrlLogHistory implements Serializable{
+
+    @Id
+    private String id;
+    private String templateId;
+    private String originUrl;
+    private String wrappedUrl;
+    private Date createDate;
+    private Date clickDate;
+    private boolean onceStat;
+    private boolean onceRedirect;
+    private String clickIp;
+    private String clickAgent;
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getTemplateId() {
+        return templateId;
+    }
+
+    public void setTemplateId(String templateId) {
+        this.templateId = templateId;
+    }
+
+    public String getOriginUrl() {
+        return originUrl;
+    }
+
+    public void setOriginUrl(String originUrl) {
+        this.originUrl = originUrl;
+    }
+
+    public String getWrappedUrl() {
+        return wrappedUrl;
+    }
+
+    public void setWrappedUrl(String wrappedUrl) {
+        this.wrappedUrl = wrappedUrl;
+    }
+
+    public Date getCreateDate() {
+        return createDate;
+    }
+
+    public void setCreateDate(Date createDate) {
+        this.createDate = createDate;
+    }
+
+    public Date getClickDate() {
+        return clickDate;
+    }
+
+    public void setClickDate(Date clickDate) {
+        this.clickDate = clickDate;
+    }
+
+    public boolean isOnceStat() {
+        return onceStat;
+    }
+
+    public void setOnceStat(boolean onceStat) {
+        this.onceStat = onceStat;
+    }
+
+    public boolean isOnceRedirect() {
+        return onceRedirect;
+    }
+
+    public void setOnceRedirect(boolean onceRedirect) {
+        this.onceRedirect = onceRedirect;
+    }
+
+    public String getClickIp() {
+        return clickIp;
+    }
+
+    public void setClickIp(String clickIp) {
+        this.clickIp = clickIp;
+    }
+
+    public String getClickAgent() {
+        return clickAgent;
+    }
+
+    public void setClickAgent(String clickAgent) {
+        this.clickAgent = clickAgent;
+    }
+}

+ 12 - 0
src/main/java/com/uas/platform/click/repository/TemplateRepository.java

@@ -0,0 +1,12 @@
+package com.uas.platform.click.repository;
+
+import com.uas.platform.click.entity.Template;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@Repository
+public interface TemplateRepository extends JpaRepository<Template, String>{
+}

+ 20 - 0
src/main/java/com/uas/platform/click/repository/UrlLogHistoryRepository.java

@@ -0,0 +1,20 @@
+package com.uas.platform.click.repository;
+
+import com.uas.platform.click.entity.UrlLogHistory;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@Repository
+@Transactional
+public interface UrlLogHistoryRepository extends JpaRepository<UrlLogHistory, String>, JpaSpecificationExecutor<UrlLogHistory>{
+
+    @Modifying
+    void deleteByTemplateId(String templateId);
+
+}

+ 20 - 0
src/main/java/com/uas/platform/click/repository/UrlLogRepository.java

@@ -0,0 +1,20 @@
+package com.uas.platform.click.repository;
+
+import com.uas.platform.click.entity.UrlLog;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@Repository
+@Transactional
+public interface UrlLogRepository extends JpaRepository<UrlLog, String>, JpaSpecificationExecutor<UrlLog>{
+
+    @Modifying
+    void deleteByTemplateId(String templateId);
+
+}

+ 41 - 0
src/main/java/com/uas/platform/click/service/TemplateService.java

@@ -0,0 +1,41 @@
+package com.uas.platform.click.service;
+
+import com.uas.platform.click.entity.Template;
+import com.uas.platform.click.repository.TemplateRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@Service
+@Transactional
+public class TemplateService {
+
+    @Autowired
+    private TemplateRepository templateRepository;
+
+    @Autowired
+    private UrlLogService urlLogService;
+
+    public Template save(Template template) {
+        return templateRepository.save(template);
+    }
+
+    public void delete(String id) {
+        templateRepository.delete(id);
+        urlLogService.deleteByTemplate(id);
+    }
+
+    public List<Template> findAll() {
+        return templateRepository.findAll();
+    }
+
+    public Template findOne(String id) {
+        return templateRepository.findOne(id);
+    }
+
+}

+ 131 - 0
src/main/java/com/uas/platform/click/service/UrlLogService.java

@@ -0,0 +1,131 @@
+package com.uas.platform.click.service;
+
+import com.uas.platform.click.entity.Template;
+import com.uas.platform.click.entity.UrlLog;
+import com.uas.platform.click.repository.UrlLogHistoryRepository;
+import com.uas.platform.click.repository.UrlLogRepository;
+import com.uas.platform.click.util.DateUtil;
+import com.uas.platform.click.util.StringUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import javax.persistence.criteria.CriteriaBuilder;
+import javax.persistence.criteria.CriteriaQuery;
+import javax.persistence.criteria.Predicate;
+import javax.persistence.criteria.Root;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+@Service
+@Transactional
+public class UrlLogService {
+
+    @Autowired
+    private UrlLogRepository urlLogRepository;
+
+    @Autowired
+    private UrlLogHistoryRepository urlLogHistoryRepository;
+
+    @Autowired
+    private TemplateService templateService;
+
+    @Value("${click.domain}")
+    private String clickDomain;
+    /**
+     * 保留天数
+     */
+    @Value("${click.remain}")
+    private int remain;
+
+    public UrlLog save(String templateId, String originUrl) {
+        UrlLog log = new UrlLog(templateId, originUrl);
+        log.setId(StringUtil.randomStr(32));
+        // https://click.ubtob.com/redirect?id=71f96c2e90197e85eaf155f6c2daf6ae
+        log.setWrappedUrl(String.format("%s/redirect?id=%s", clickDomain, log.getId()));
+        Template template = templateService.findOne(templateId);
+        if (null != template) {
+            log.setOnceStat(template.isOnceStat());
+            log.setOnceRedirect(template.isOnceRedirect());
+            template.setTotalUrlSize(template.getTotalUrlSize() + 1);
+            templateService.save(template);
+        }
+        return urlLogRepository.save(log);
+    }
+
+    public UrlLog findOne(String id) {
+        return urlLogRepository.findOne(id);
+    }
+
+    public void setClicked(UrlLog log) {
+        // isOnce的只需记录一次
+        if (null == log.getClickDate() || !log.isOnceStat()) {
+            Template template = templateService.findOne(log.getTemplateId());
+            if (null != template) {
+                template.setClickedUrlSize(template.getClickedUrlSize() + 1);
+                templateService.save(template);
+            }
+        }
+        log.setClickDate(new Date());
+        urlLogRepository.save(log);
+    }
+
+    public Page<UrlLog> findByCondition(final String templateId, final boolean onlyClicked, final boolean onlyNotClicked, Pageable pageable) {
+        return urlLogRepository.findAll(new Specification<UrlLog>() {
+            @Override
+            public Predicate toPredicate(Root<UrlLog> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
+                List<Predicate> predicates = new ArrayList<Predicate>();
+                if (!StringUtils.isEmpty(templateId)) {
+                    predicates.add(cb.equal(root.get("templateId"), templateId));
+                }
+                if (onlyClicked) {
+                    predicates.add(cb.isNotNull(root.get("clickDate")));
+                } else if (onlyNotClicked) {
+                    predicates.add(cb.isNull(root.get("clickDate")));
+                }
+                return cb.and(predicates.toArray(new Predicate[]{}));
+            }
+        }, pageable);
+    }
+
+    private List<UrlLog> getHistoryLogs() {
+        return urlLogRepository.findAll(new Specification<UrlLog>() {
+            @Override
+            public Predicate toPredicate(Root<UrlLog> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
+                return cb.and(cb.lessThanOrEqualTo(root.get("createDate"), DateUtil.beforeDays(remain)),
+                        cb.notEqual(root.get("once"), true));
+            }
+        });
+    }
+
+    /**
+     * 转移到历史表
+     */
+    @Scheduled(cron = "0 30 0 * * ?")
+    public void toHistory() {
+        List<UrlLog> logs = getHistoryLogs();
+        if (!CollectionUtils.isEmpty(logs)) {
+            urlLogHistoryRepository.save(logs.parallelStream().map(UrlLog::toHistory)
+                    .collect(Collectors.toList()));
+            urlLogRepository.delete(logs);
+        }
+    }
+
+    public void deleteByTemplate(String templateId) {
+        urlLogRepository.deleteByTemplateId(templateId);
+        urlLogHistoryRepository.deleteByTemplateId(templateId);
+    }
+
+}

+ 19 - 0
src/main/java/com/uas/platform/click/util/DateUtil.java

@@ -0,0 +1,19 @@
+package com.uas.platform.click.util;
+
+import java.util.Calendar;
+import java.util.Date;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+public class DateUtil {
+
+    final static int ONE_DAY_MILLIS = 86400000;
+
+    public static Date beforeDays(int days) {
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTimeInMillis(calendar.getTimeInMillis() - days * ONE_DAY_MILLIS);
+        return calendar.getTime();
+    }
+
+}

+ 40 - 0
src/main/java/com/uas/platform/click/util/IpUtil.java

@@ -0,0 +1,40 @@
+package com.uas.platform.click.util;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+public class IpUtil {
+
+    /**
+     * 获取客户端IP
+     *
+     * @param request
+     * @return
+     */
+    public static String getRequestIp(HttpServletRequest request) {
+        String ipAddress = null;
+        ipAddress = request.getHeader("X-Forwarded-For");
+        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
+            ipAddress = request.getHeader("Proxy-Client-IP");
+        }
+        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
+            ipAddress = request.getHeader("WL-Proxy-Client-IP");
+        }
+        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
+            ipAddress = request.getRemoteAddr();
+        }
+        // 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
+        if (ipAddress != null && ipAddress.length() > 15) { // "***.***.***.***".length()
+            // = 15
+            if (ipAddress.indexOf(",") > 0) {
+                ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
+            }
+        }
+        if (ipAddress != null && "0:0:0:0:0:0:0:1".equals(ipAddress)) {// window7系统下,用localhost访问时,ip会变成0:0:0:0:0:0:0:1
+            ipAddress = "127.0.0.1";
+        }
+        return ipAddress;
+    }
+}

+ 25 - 0
src/main/java/com/uas/platform/click/util/RequestUtil.java

@@ -0,0 +1,25 @@
+package com.uas.platform.click.util;
+
+import org.springframework.util.StringUtils;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+public class RequestUtil {
+
+    public static boolean isAjax(HttpServletRequest request) {
+        String with = request.getHeader("x-requested-with");
+        return null != with && with.equalsIgnoreCase("XMLHttpRequest");
+    }
+
+    public static String getUri(HttpServletRequest request) {
+        String url = request.getRequestURI();
+        String query = request.getQueryString();
+        if (!StringUtils.isEmpty(query)) {
+            url += '?' + query;
+        }
+        return url;
+    }
+}

+ 29 - 0
src/main/java/com/uas/platform/click/util/StringUtil.java

@@ -0,0 +1,29 @@
+package com.uas.platform.click.util;
+
+import java.util.Random;
+
+/**
+ * Created by Pro1 on 2018/2/9.
+ */
+public class StringUtil {
+
+    final static String CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
+
+    /**
+     * 随机串
+     *
+     * @param length
+     * @return
+     */
+    public static String randomStr(int length) {
+        int len = CHARS.length();
+        Random random = new Random();
+        StringBuffer str = new StringBuffer();
+
+        for (int i = 0; i < length; i++) {
+            str.append(CHARS.charAt(random.nextInt(len)));
+        }
+        return str.toString();
+    }
+
+}

+ 97 - 0
src/main/java/com/uas/platform/click/web/ResponseWrap.java

@@ -0,0 +1,97 @@
+package com.uas.platform.click.web;
+
+import com.alibaba.fastjson.JSON;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.PrintWriter;
+
+/**
+ * Created by Pro1 on 2017/6/20.
+ */
+public class ResponseWrap {
+
+    private static final String successParam = "success";
+
+    private static final String responseCodeParam = "code";
+
+    private static final String responseMessageParam = "message";
+
+    private static final String responseContentParam = "content";
+
+    public static ModelMap success() {
+        return new ModelMap(successParam, true);
+    }
+
+    public static <T> ModelMap success(T content) {
+        return success().addAttribute(responseContentParam, content);
+    }
+
+    public static ModelMap error() {
+        return new ModelMap(successParam, false);
+    }
+
+    public static ModelMap error(String message) {
+        return error().addAttribute(responseMessageParam, message);
+    }
+
+    public static <T> ModelMap error(int code) {
+        return error().addAttribute(responseCodeParam, code);
+    }
+
+    public static <T> ModelMap error(int code, String message) {
+        return error(code).addAttribute(responseMessageParam, message);
+    }
+
+    public static ResponseEntity ok() {
+        return ResponseEntity.ok(success());
+    }
+
+    public static <T> ResponseEntity ok(T content) {
+        return ResponseEntity.ok(success(content));
+    }
+
+    public static <T> void ok(HttpServletResponse response, T content) throws IOException{
+        response.setStatus(HttpStatus.OK.value());
+        response.setContentType(MediaType.APPLICATION_JSON_UTF8.toString());
+        PrintWriter printWriter = response.getWriter();
+        printWriter.append(JSON.toJSONString(success(content)));
+        printWriter.flush();
+        printWriter.close();
+    }
+
+    public static <T> void ok(HttpServletResponse response) throws IOException {
+        ok(response, null);
+    }
+
+    public static ResponseEntity badRequest() {
+        // do not use ResponseEntity.badRequest()
+        return ResponseEntity.ok(error());
+    }
+
+    public static <T> ResponseEntity badRequest(String message) {
+        return ResponseEntity.ok(error(message));
+    }
+
+    public static <T> void badRequest(HttpServletResponse response, int code, String message) throws IOException{
+        response.setStatus(HttpStatus.OK.value());
+        response.setContentType(MediaType.APPLICATION_JSON_UTF8.toString());
+        PrintWriter printWriter = response.getWriter();
+        printWriter.append(JSON.toJSONString(error(code, message)));
+        printWriter.flush();
+        printWriter.close();
+    }
+
+    public static <T> void badRequest(HttpServletResponse response, String message) throws IOException {
+        badRequest(response, HttpStatus.BAD_REQUEST.value(), message);
+    }
+
+    public static <T> void badRequest(HttpServletResponse response) throws IOException {
+        badRequest(response, HttpStatus.BAD_REQUEST.value(), null);
+    }
+
+}

+ 27 - 0
src/main/resources/application.yml

@@ -0,0 +1,27 @@
+server:
+  contextPath: /
+  tomcat:
+    uri-encoding: UTF-8
+spring:
+  http:
+    encoding:
+      force: true
+      charset: utf-8
+      enabled: true
+  datasource:
+    url: jdbc:mysql://10.10.100.18:3306/click_service?characterEncoding=utf-8&allowMultiQueries=true&rewriteBatchedStatements=true
+    username: root
+    password: select
+    driverClassName: com.mysql.jdbc.Driver
+  jpa:
+    database: MYSQL
+    show-sql: false
+    hibernate:
+      ddl-auto: update
+      naming-strategy: org.hibernate.cfg.ImprovedNamingStrategy
+      dialect: org.hibernate.dialect.MySQL5Dialect
+
+click:
+  domain: https://click.ubtob.com/v1
+  # The maximum number of days a log can remain in the table of UrlLog
+  remain: 60

+ 39 - 0
src/main/resources/static/css/dashboard.css

@@ -0,0 +1,39 @@
+body {
+  font-size: .875rem;
+}
+
+.feather {
+  width: 16px;
+  height: 16px;
+  vertical-align: text-bottom;
+}
+
+.form-control-dark {
+  color: #fff;
+  background-color: rgba(255, 255, 255, .1);
+  border-color: rgba(255, 255, 255, .1);
+}
+
+.form-control-dark:focus {
+  border-color: transparent;
+  box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
+}
+
+/*
+ * Utilities
+ */
+
+.border-top { border-top: 1px solid #e5e5e5; }
+.border-bottom { border-bottom: 1px solid #e5e5e5; }
+
+.btn-blank {
+  padding: 0 0;
+  font-size: 1rem;
+  border-radius: inherit;
+}
+
+.text-ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}

+ 87 - 0
src/main/resources/static/css/login.css

@@ -0,0 +1,87 @@
+:root {
+    --input-padding-x: .75rem;
+    --input-padding-y: .75rem;
+}
+
+html,
+body {
+    height: 100%;
+}
+
+body {
+    display: -ms-flexbox;
+    display: -webkit-box;
+    display: flex;
+    -ms-flex-align: center;
+    -ms-flex-pack: center;
+    -webkit-box-align: center;
+    align-items: center;
+    -webkit-box-pack: center;
+    justify-content: center;
+    padding-top: 40px;
+    padding-bottom: 40px;
+    background-color: #f5f5f5;
+}
+
+.form-signin {
+    width: 100%;
+    max-width: 420px;
+    padding: 15px;
+    margin: 0 auto;
+}
+
+.form-label-group {
+    position: relative;
+    margin-bottom: 1rem;
+}
+
+.form-label-group > input,
+.form-label-group > label {
+    padding: var(--input-padding-y) var(--input-padding-x);
+}
+
+.form-label-group > label {
+    position: absolute;
+    top: 0;
+    left: 0;
+    display: block;
+    width: 100%;
+    margin-bottom: 0; /* Override default `<label>` margin */
+    line-height: 1.5;
+    color: #495057;
+    border: 1px solid transparent;
+    border-radius: .25rem;
+    transition: all .1s ease-in-out;
+}
+
+.form-label-group input::-webkit-input-placeholder {
+    color: transparent;
+}
+
+.form-label-group input:-ms-input-placeholder {
+    color: transparent;
+}
+
+.form-label-group input::-ms-input-placeholder {
+    color: transparent;
+}
+
+.form-label-group input::-moz-placeholder {
+    color: transparent;
+}
+
+.form-label-group input::placeholder {
+    color: transparent;
+}
+
+.form-label-group input:not(:placeholder-shown) {
+    padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
+    padding-bottom: calc(var(--input-padding-y) / 3);
+}
+
+.form-label-group input:not(:placeholder-shown) ~ label {
+    padding-top: calc(var(--input-padding-y) / 3);
+    padding-bottom: calc(var(--input-padding-y) / 3);
+    font-size: 12px;
+    color: #777;
+}

+ 134 - 0
src/main/resources/static/dashboard.html

@@ -0,0 +1,134 @@
+<!DOCTYPE HTML>
+<html lang="zh-CN">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <title>链接代理服务控制台</title>
+    <link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/4.0.0/css/bootstrap.min.css"
+          integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
+    <link rel="stylesheet" href="/css/dashboard.css" />
+</head>
+<body>
+<div class="container-fluid">
+    <div class="row">
+        <main role="main" class="col-md-12">
+            <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-1 mb-2 pt-1 mt-2">
+                <h1 class="h2">链接代理服务控制台</h1>
+                <div class="btn-toolbar mb-2 mb-md-0">
+                    <div class="btn-group">
+                        <button class="btn btn-sm btn-outline-secondary" data-toggle="modal" data-target="#addTemplateModal">添加</button>
+                        <button class="btn btn-sm btn-outline-secondary btn-refresh">刷新</button>
+                    </div>
+                </div>
+            </div>
+
+            <div class="table-responsive">
+                <table id="template-list" class="table table-striped table-sm">
+                    <thead>
+                    <tr>
+                        <th width="300">ID</th>
+                        <th>描述</th>
+                        <th width="80" class="text-center">统计一次</th>
+                        <th width="80" class="text-center">跳转一次</th>
+                        <th width="100" class="text-center">总链接数</th>
+                        <th width="100" class="text-center">点击数</th>
+                        <th width="100" class="text-center">点击率</th>
+                        <th width="140" class="text-center">操作</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    </tbody>
+                </table>
+            </div>
+        </main>
+    </div>
+</div>
+<script id="template-html" type="text/html">
+    <%for(var i in content){%>
+    <tr>
+        <td><%=content[i].id%></td>
+        <td><%=content[i].title%></td>
+        <td class="text-center"><%=content[i].onceStat ? '是' : '否'%></td>
+        <td class="text-center"><%=content[i].onceRedirect ? '是' : '否'%></td>
+        <td class="text-center"><%=content[i].totalUrlSize%></td>
+        <td class="text-center"><%=content[i].clickedUrlSize%></td>
+        <td class="text-center">
+            <%=content[i].totalUrlSize ? (Math.round(content[i].clickedUrlSize / content[i].totalUrlSize * 10000) / 100.00 + "%") : ''%>
+        </td>
+        <td class="text-center">
+            <a href="#" data-toggle="modal" data-target="#wrapUrlModal" title="创建代理链接" class="btn btn-link btn-blank btn-create" data-id="<%=content[i].id%>" role="button">&plus;</a>
+            <a href="/dashboard/log?templateId=<%=content[i].id%>" title="查看链接日志" class="btn btn-link btn-blank btn-more" data-id="<%=content[i].id%>" role="button">&raquo;</a>
+            <a href="#" title="删除模板" class="btn btn-link btn-blank btn-delete" data-id="<%=content[i].id%>" role="button">&times;</a>
+        </td>
+    </tr>
+    <%}%>
+</script>
+<!-- add template -->
+<div class="modal fade" id="addTemplateModal" tabindex="-1" role="dialog">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">新增模板</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <form id="template-form">
+                    <div class="form-group">
+                        <label for="template-title" class="col-form-label">描述:</label>
+                        <textarea class="form-control" id="template-title" name="title" required></textarea>
+                    </div>
+                    <div class="form-check">
+                        <input type="checkbox" class="form-check-input" id="template-once-stat" name="onceStat" checked>
+                        <label class="form-check-label" for="template-once-stat">多次点击只统计一次</label>
+                    </div>
+                    <div class="form-check">
+                        <input type="checkbox" class="form-check-input" id="template-once-redirect" name="onceRedirect">
+                        <label class="form-check-label" for="template-once-redirect">只允许点击、跳转一次</label>
+                    </div>
+                </form>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
+                <button type="button" class="btn btn-primary" id="save-template-btn">保存</button>
+            </div>
+        </div>
+    </div>
+</div>
+<!-- wrap url -->
+<div class="modal fade" id="wrapUrlModal" tabindex="-1" role="dialog">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">创建代理链接</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <form id="wrap-form">
+                    <div class="form-group">
+                        <label for="originUrl" class="col-form-label">原链接:</label>
+                        <textarea class="form-control" id="originUrl" name="originUrl" required></textarea>
+                    </div>
+                    <div id="wrapped-div" style="display: none">
+                        <hr>
+                        <h5>代理链接</h5>
+                        <p id="wrappedUrl"></p>
+                    </div>
+                </form>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary" data-dismiss="modal">关闭</button>
+                <button type="button" class="btn btn-primary btn-wrap">创建</button>
+            </div>
+        </div>
+    </div>
+</div>
+<script src="https://cdn.bootcss.com/jquery/3.2.1/jquery.min.js"></script>
+<script src="https://cdn.bootcss.com/bootstrap/4.0.0/js/bootstrap.min.js"></script>
+<script src="https://cdn.bootcss.com/template_js/0.7.1/template.min.js"></script>
+<script src="/js/dashboard.js"></script>
+</body>
+</html>

+ 46 - 0
src/main/resources/static/invalid.html

@@ -0,0 +1,46 @@
+<html lang="zh-CN">
+<head>
+    <meta charset="utf-8" />
+    <title>错误操作</title>
+    <style>
+        body {
+            background-color: #f1f3f6;
+            color: #555;
+            font: 400 1em/1.45 Helvetica Neue,Helvetica,Arial,Hiragino Sans GB,STXihei,STHeiti,Microsoft YaHei,SimHei,sans-serif;
+        }
+        .x-container {
+            width: 990px;
+            margin: 30px auto
+        }
+        .x-well {
+            border: 1px solid #e4e7ed;
+            border-bottom-width: 0;
+            border-radius: 4px;
+            box-shadow: 0 1px 4px 0 rgba(204,209,217,.3);
+        }
+        .x-well .x-title{
+            padding: 24px 30px 20px;
+            border-bottom: 1px solid #e4e7ed;
+            border-radius: 4px 4px 0 0;
+            background-color: #f5f7fa;
+            box-shadow: inset 0 1px 0 0 rgba(255,255,255,.6);
+        }
+        .x-well .x-block{
+            font-size: 14px;
+            padding: 25px 30px;
+            border-bottom: 1px solid #e4e7ed;
+            background-color: #fff;
+        }
+    </style>
+</head>
+<body>
+<div class="x-container">
+    <div class="x-well">
+        <div class="x-title">错误操作</div>
+        <div class="x-block">
+            <p>您的链接已失效</p>
+        </div>
+    </div>
+</div>
+</body>
+</html>

+ 95 - 0
src/main/resources/static/js/dashboard.js

@@ -0,0 +1,95 @@
+$(document).ready(function () {
+    var api = {
+        getTemplates: function() {
+            $.get('/v1/template').then(function(data){
+                if (data.success) {
+                    $('#template-list tbody').html(template($('#template-html').text(), data));
+                } else {
+                    alert(data.message);
+                }
+            });
+        },
+        saveTemplate: function(formdata) {
+            $.post('/v1/template', formdata).then(function(data){
+                if (data.success) {
+                    api.getTemplates();
+                } else {
+                    alert(data.message);
+                }
+            });
+        },
+        deleteTemplate: function(id) {
+            $.ajax({
+                url: '/v1/template?id=' + id,
+                type: "delete",
+                success: function(data){
+                    if (data.success) {
+                        api.getTemplates();
+                    } else {
+                        alert(data.message);
+                    }
+                }
+            });
+        },
+        wrapUrl: function(templateId, url) {
+            $.get('/v1/wrap', {
+                templateId: templateId,
+                url: url
+            }).then(function(data){
+                if (data.success) {
+                    $('#wrappedUrl').html(template('<a href="<%=content.wrappedUrl%>" target="_blank"><%=content.wrappedUrl%></a>', data));
+                    $('#wrapped-div').show();
+                } else {
+                    alert(data.message);
+                }
+            });
+        }
+    }, app = {
+        listeners: {
+            '#save-template-btn': function() {
+                api.saveTemplate({
+                    title: $('#template-title').val(),
+                    onceStat: $('#template-once-stat').is(':checked'),
+                    onceRedirect: $('#template-once-redirect').is(':checked')
+                });
+                $('.modal').hide();
+                $('.modal-backdrop').hide();
+            },
+            '.btn-refresh': function(){
+                api.getTemplates();
+            },
+            '.btn-delete': function(){
+                api.deleteTemplate($(this).data('id'));
+            },
+            '#wrapUrlModal': {
+                'show.bs.modal': function(event) {
+                    var button = $(event.relatedTarget);
+                    app.selectedTemplateId = button.data('id');
+                },
+                'hidden.bs.modal': function(event) {
+                    app.selectedTemplateId = null;
+                    $('#wrappedUrl').html('');
+                    $('#wrapped-div').hide();
+                    api.getTemplates();
+                }
+            },
+            '.btn-wrap': function(){
+                var url = $('#originUrl').val();
+                url && api.wrapUrl(app.selectedTemplateId, url);
+            }
+        },
+        init: function () {
+            api.getTemplates();
+            $.each(app.listeners, function(selector, fn){
+                if (typeof fn === 'object') {
+                    $.each(fn, function(event, f){
+                        $(document).on(event, selector, f);
+                    });
+                } else if (typeof fn === 'function'){
+                    $(document).on('click', selector, fn);
+                }
+            });
+        }
+    };
+    app.init();
+});

+ 69 - 0
src/main/resources/static/js/log.js

@@ -0,0 +1,69 @@
+Date.prototype.format = function(format) {
+    var value = this;
+    var date = {
+        "m+" : value.getMonth() + 1,
+        "d+" : value.getDate(),
+        "h+" : value.getHours(),
+        "i+" : value.getMinutes(),
+        "s+" : value.getSeconds(),
+        "q+" : Math.floor((value.getMonth() + 3) / 3),
+        "S+" : value.getMilliseconds()
+    };
+    if (/(y+)/i.test(format)) {
+        format = format.replace(RegExp.$1, (value.getFullYear() + '').substr(4 - RegExp.$1.length));
+    }
+    for ( var k in date) {
+        if (new RegExp("(" + k + ")").test(format)) {
+            format = format.replace(RegExp.$1, RegExp.$1.length == 1 ? date[k] : ("00" + date[k])
+                .substr(("" + date[k]).length));
+        }
+    }
+    return format;
+};
+$(document).ready(function () {
+    var getUrlParam = function(name){
+        var reg = new RegExp("(^|&)" + name +"=([^&]*)(&|$)");
+        var r = window.location.search.substr(1).match(reg);
+        return r ? unescape(r[2]) : null;
+    }, templateId = getUrlParam('templateId'), currentPage = 0, api = {
+        getLogs: function() {
+            $.get('/v1/log', {
+                templateId: templateId,
+                page: currentPage
+            }).then(function(data){
+                if (data.success) {
+                    var pageInfo = data.content;
+                    $('#log-list tbody').html(template($('#log-html').text(), pageInfo));
+                    if (pageInfo.totalElements) {
+                        $('.btn-group-pagination').show();
+                        $('.btn-prev').toggleClass('disabled', pageInfo.first);
+                        $('.btn-next').toggleClass('disabled', pageInfo.last);
+                        $('.btn-current').text('第' + (pageInfo.number + 1) + '/' + pageInfo.totalPages + '页');
+                    } else {
+                        $('.btn-group-pagination').hide();
+                    }
+                } else {
+                    alert(data.message);
+                }
+            });
+        }
+    }, app = {
+        init: function () {
+            api.getLogs();
+            $('.btn-refresh').click(function(){
+                api.getLogs();
+            });
+            $('.btn-prev').click(function(){
+                if (currentPage > 0) {
+                    currentPage--;
+                    api.getLogs();
+                }
+            });
+            $('.btn-next').click(function(){
+                currentPage++;
+                api.getLogs();
+            });
+        }
+    };
+    app.init();
+});

+ 78 - 0
src/main/resources/static/log.html

@@ -0,0 +1,78 @@
+<!DOCTYPE HTML>
+<html lang="zh-CN">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <title>链接代理服务控制台</title>
+    <link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/4.0.0/css/bootstrap.min.css"
+          integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
+    <link rel="stylesheet" href="/css/dashboard.css" />
+</head>
+<body>
+<div class="container-fluid">
+    <div class="row">
+        <main role="main" class="col-md-12">
+            <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-1 mb-2 pt-1 mt-2">
+                <h1 class="h2">链接代理服务控制台</h1>
+                <div class="btn-toolbar mb-2 mb-md-0">
+                    <div class="btn-group">
+                        <a href="/dashboard" class="btn btn-sm btn-outline-secondary" role="button">返回</a>
+                        <a href="#" class="btn btn-sm btn-outline-secondary" role="button">刷新</a>
+                    </div>
+                    <div class="btn-group btn-group-pagination ml-2" style="display: none">
+                        <a href="#" class="btn btn-sm btn-outline-secondary btn-prev" role="button">上一页</a>
+                        <a href="#" class="btn btn-sm btn-outline-secondary btn-current btn-disabled" role="button">第1页</a>
+                        <a href="#" class="btn btn-sm btn-outline-secondary btn-next" role="button">下一页</a>
+                    </div>
+                </div>
+            </div>
+
+            <div class="table-responsive">
+                <table id="log-list" class="table table-striped table-sm">
+                    <thead>
+                    <tr>
+                        <th>原链接</th>
+                        <th>代理链接</th>
+                        <th width="140" class="text-center">创建时间</th>
+                        <th width="140" class="text-center">点击时间</th>
+                        <th width="240">客户端</th>
+                        <th width="120" class="text-center">客户端IP</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    </tbody>
+                </table>
+            </div>
+        </main>
+    </div>
+</div>
+<script id="log-html" type="text/html">
+    <%for(var i in content){%>
+    <tr>
+        <td>
+            <a href="<%=content[i].originUrl%>" target="_blank">
+                <%=content[i].originUrl%>
+            </a>
+        </td>
+        <td>
+            <a href="<%=content[i].wrappedUrl%>" target="_blank">
+                <%=content[i].wrappedUrl%>
+            </a>
+        </td>
+        <td class="text-center">
+            <%=content[i].createDate ? new Date(content[i].createDate).format('yyyy-mm-dd h:i:s') : ''%>
+        </td>
+        <td class="text-center">
+            <%=content[i].clickDate ? new Date(content[i].clickDate).format('yyyy-mm-dd h:i:s') : ''%>
+        </td>
+        <td class="text-ellipsis"><%=content[i].clickAgent || ''%></td>
+        <td class="text-center"><%=content[i].clickIp || ''%></td>
+    </tr>
+    <%}%>
+</script>
+<script src="https://cdn.bootcss.com/jquery/3.2.1/jquery.min.js"></script>
+<script src="https://cdn.bootcss.com/bootstrap/4.0.0/js/bootstrap.min.js"></script>
+<script src="https://cdn.bootcss.com/template_js/0.7.1/template.min.js"></script>
+<script src="/js/log.js"></script>
+</body>
+</html>

+ 32 - 0
src/main/resources/static/login.html

@@ -0,0 +1,32 @@
+<!DOCTYPE HTML>
+<html lang="zh-CN">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <title>链接服务控制台</title>
+    <link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/4.0.0/css/bootstrap.min.css"
+          integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
+    <link rel="stylesheet" href="/css/login.css" />
+</head>
+<body>
+<form class="form-signin" action="" method="POST">
+    <div class="text-center mb-4">
+        <h1 class="h3 mb-3 font-weight-normal">账户登录</h1>
+    </div>
+    <div class="form-label-group">
+        <input type="text" id="inputUsername" name="username" class="form-control" placeholder="账户" required autofocus>
+        <label for="inputUsername">账户</label>
+    </div>
+    <div class="form-label-group">
+        <input type="password" id="inputPassword" name="password" class="form-control" placeholder="密码" required>
+        <label for="inputPassword">密码</label>
+    </div>
+    <div class="checkbox mb-3">
+        <label>
+            <input type="checkbox" checked name="remember-me" value="remember-me"> 记住用户
+        </label>
+    </div>
+    <button class="btn btn-lg btn-primary btn-block" type="submit">登录</button>
+</form>
+</body>
+</html>