Merge remote-tracking branch 'origin/main' into eh4pzj-codex/add-whitelist-invitation-registration-mode

This commit is contained in:
tim
2025-07-14 22:05:27 +08:00
15 changed files with 334 additions and 12 deletions

View File

@@ -9,11 +9,13 @@
"version": "0.1.0",
"dependencies": {
"core-js": "^3.8.3",
"echarts": "^5.6.0",
"ldrs": "^1.1.7",
"markdown-it": "^14.1.0",
"vditor": "^3.8.7",
"vue": "^3.2.13",
"vue-easy-lightbox": "^1.19.0",
"vue-echarts": "^7.0.3",
"vue-router": "^4.5.1",
"vue-toastification": "^2.0.0-rc.5"
},
@@ -5225,6 +5227,22 @@
"node": ">=6.0.0"
}
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
@@ -11344,6 +11362,32 @@
}
}
},
"node_modules/vue-demi": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
"integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-easy-lightbox": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/vue-easy-lightbox/-/vue-easy-lightbox-1.19.0.tgz",
@@ -11356,6 +11400,25 @@
"vue": "^3.0.0"
}
},
"node_modules/vue-echarts": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-7.0.3.tgz",
"integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==",
"license": "MIT",
"dependencies": {
"vue-demi": "^0.13.11"
},
"peerDependencies": {
"@vue/runtime-core": "^3.0.0",
"echarts": "^5.5.1",
"vue": "^2.7.0 || ^3.1.1"
},
"peerDependenciesMeta": {
"@vue/runtime-core": {
"optional": true
}
}
},
"node_modules/vue-eslint-parser": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz",
@@ -12272,6 +12335,21 @@
"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==",
"dev": true,
"license": "ISC"
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}

View File

@@ -15,7 +15,9 @@
"vue": "^3.2.13",
"vue-router": "^4.5.1",
"vue-toastification": "^2.0.0-rc.5",
"vue-easy-lightbox": "^1.19.0"
"vue-easy-lightbox": "^1.19.0",
"echarts": "^5.6.0",
"vue-echarts": "^7.0.3"
},
"devDependencies": {
"@babel/core": "^7.12.16",

View File

@@ -17,6 +17,15 @@
<i class="menu-item-icon fas fa-info-circle"></i>
<span class="menu-item-text">关于</span>
</router-link>
<router-link
v-if="shouldShowStats"
class="menu-item"
exact-active-class="selected"
to="/about/stats"
>
<i class="menu-item-icon fas fa-chart-line"></i>
<span class="menu-item-text">站点统计</span>
</router-link>
<router-link class="menu-item" exact-active-class="selected" to="/new-post">
<i class="menu-item-icon fas fa-edit"></i>
<span class="menu-item-text">发帖</span>
@@ -109,6 +118,9 @@ export default {
},
showUnreadCount() {
return this.unreadCount > 99 ? '99+' : this.unreadCount
},
shouldShowStats() {
return authState.role === 'ADMIN'
}
},
async mounted() {

View File

@@ -41,7 +41,7 @@ const fetchTypes = async () => {
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
headers: { Authorization: `Bearer ${token}` }
headers: { Authorization: token ? `Bearer ${token}` : '' }
})
if (res.ok) {
cachedTypes = await res.json()

View File

@@ -9,14 +9,14 @@ import { checkToken, clearToken } from './utils/auth'
import { initTheme } from './utils/theme'
// Configurable API domain and port
// export const API_DOMAIN = 'http://127.0.0.1'
// export const API_PORT = 8081
export const API_DOMAIN = 'http://127.0.0.1'
export const API_PORT = 8081
export const API_DOMAIN = 'http://47.82.99.208'
export const API_PORT = 8080
// export const API_DOMAIN = 'http://47.82.99.208'
// export const API_PORT = 8080
// export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN
export const API_BASE_URL = "";
export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN
// export const API_BASE_URL = "";
export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
export const toast = useToast()

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import HomePageView from '../views/HomePageView.vue'
import MessagePageView from '../views/MessagePageView.vue'
import AboutPageView from '../views/AboutPageView.vue'
import SiteStatsPageView from '../views/SiteStatsPageView.vue'
import PostPageView from '../views/PostPageView.vue'
import LoginPageView from '../views/LoginPageView.vue'
import SignupPageView from '../views/SignupPageView.vue'
@@ -27,6 +28,11 @@ const routes = [
name: 'about',
component: AboutPageView
},
{
path: '/about/stats',
name: 'site-stats',
component: SiteStatsPageView
},
{
path: '/new-post',
name: 'new-post',

View File

@@ -60,11 +60,10 @@ export default {
<style scoped>
.about-page {
padding: 20px;
max-width: var(--page-max-width);
background-color: var(--background-color);
margin: 0 auto;
height: calc(100vh - var(--header-height) - 40px);
height: calc(100vh - var(--header-height));
overflow-y: auto;
}
@@ -87,5 +86,6 @@ export default {
.about-content {
line-height: 1.6;
padding: 20px;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="site-stats-page">
<VChart v-if="option" :option="option" :autoresize="true" style="height:400px" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, GridComponent, DataZoomComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { API_BASE_URL } from '../main'
import { getToken } from '../utils/auth'
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
const option = ref(null)
async function loadData() {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/stats/dau-range?days=30`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
data.sort((a, b) => new Date(a.date) - new Date(b.date))
const dates = data.map(d => d.date)
const values = data.map(d => d.value)
option.value = {
title: { text: '站点 DAU' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: dates },
yAxis: { type: 'value' },
dataZoom: [{ type: 'slider', start: 80 }, { type: 'inside' }],
series: [{ type: 'line', areaStyle: {}, smooth: true, data: values }]
}
}
}
onMounted(loadData)
</script>
<style scoped>
.site-stats-page {
padding: 20px;
max-width: var(--page-max-width);
background-color: var(--background-color);
margin: 0 auto;
height: calc(100vh - var(--header-height) - 40px);
}
</style>

View File

@@ -1,6 +1,7 @@
package com.openisle.config;
import com.openisle.service.JwtService;
import com.openisle.service.UserVisitService;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
@@ -38,6 +39,7 @@ public class SecurityConfig {
private final JwtService jwtService;
private final UserRepository userRepository;
private final AccessDeniedHandler customAccessDeniedHandler;
private final UserVisitService userVisitService;
@Bean
public PasswordEncoder passwordEncoder() {
@@ -96,6 +98,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/search/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
@@ -103,7 +106,8 @@ public class SecurityConfig {
.requestMatchers("/api/admin/**").hasAuthority("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@@ -123,7 +127,8 @@ public class SecurityConfig {
boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) &&
(uri.startsWith("/api/posts") || uri.startsWith("/api/comments") ||
uri.startsWith("/api/categories") || uri.startsWith("/api/tags") ||
uri.startsWith("/api/search") || uri.startsWith("/api/users"));
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
@@ -150,4 +155,18 @@ public class SecurityConfig {
}
};
}
@Bean
public OncePerRequestFilter userVisitFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
userVisitService.recordVisit(auth.getName());
}
filterChain.doFilter(request, response);
}
};
}
}

View File

@@ -0,0 +1,41 @@
package com.openisle.controller;
import com.openisle.service.UserVisitService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
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;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatController {
private final UserVisitService userVisitService;
@GetMapping("/dau")
public Map<String, Long> dau(@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
long count = userVisitService.countDau(date);
return Map.of("dau", count);
}
@GetMapping("/dau-range")
public List<Map<String, Object>> dauRange(@RequestParam(value = "days", defaultValue = "30") int days) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(days - 1L);
var data = userVisitService.countDauRange(start, end);
return data.entrySet().stream()
.map(e -> Map.<String,Object>of(
"date", e.getKey().toString(),
"value", e.getValue()
))
.toList();
}
}

View File

@@ -3,6 +3,8 @@ package com.openisle.repository;
import com.openisle.model.User;
import com.openisle.model.UserVisit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Optional;
@@ -10,4 +12,8 @@ import java.util.Optional;
public interface UserVisitRepository extends JpaRepository<UserVisit, Long> {
Optional<UserVisit> findByUserAndVisitDate(User user, LocalDate visitDate);
long countByUser(User user);
long countByVisitDate(LocalDate visitDate);
@Query("SELECT uv.visitDate AS d, COUNT(uv) AS c FROM UserVisit uv WHERE uv.visitDate BETWEEN :start AND :end GROUP BY uv.visitDate ORDER BY uv.visitDate")
java.util.List<Object[]> countRange(@Param("start") LocalDate start, @Param("end") LocalDate end);
}

View File

@@ -8,6 +8,8 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -32,4 +34,27 @@ public class UserVisitService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return userVisitRepository.countByUser(user);
}
public long countDau(LocalDate date) {
LocalDate d = date != null ? date : LocalDate.now();
return userVisitRepository.countByVisitDate(d);
}
public Map<LocalDate, Long> countDauRange(LocalDate start, LocalDate end) {
Map<LocalDate, Long> result = new LinkedHashMap<>();
if (start == null || end == null || start.isAfter(end)) {
return result;
}
var list = userVisitRepository.countRange(start, end);
for (var obj : list) {
LocalDate d = (LocalDate) obj[0];
Long c = (Long) obj[1];
result.put(d, c);
}
// fill zero counts for missing dates
for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {
result.putIfAbsent(d, 0L);
}
return result;
}
}

View File

@@ -11,6 +11,7 @@ import com.openisle.config.CustomAccessDeniedHandler;
import com.openisle.config.SecurityConfig;
import com.openisle.service.JwtService;
import com.openisle.repository.UserRepository;
import com.openisle.service.UserVisitService;
import com.openisle.model.Role;
import com.openisle.model.User;
import java.util.Optional;
@@ -31,6 +32,8 @@ class AdminControllerTest {
private JwtService jwtService;
@MockBean
private UserRepository userRepository;
@MockBean
private UserVisitService userVisitService;
@Test
void adminHelloReturnsMessage() throws Exception {

View File

@@ -11,6 +11,7 @@ import com.openisle.config.CustomAccessDeniedHandler;
import com.openisle.config.SecurityConfig;
import com.openisle.service.JwtService;
import com.openisle.repository.UserRepository;
import com.openisle.service.UserVisitService;
import com.openisle.model.Role;
import com.openisle.model.User;
import java.util.Optional;
@@ -31,6 +32,8 @@ class HelloControllerTest {
private JwtService jwtService;
@MockBean
private UserRepository userRepository;
@MockBean
private UserVisitService userVisitService;
@Test
void helloReturnsMessage() throws Exception {

View File

@@ -0,0 +1,74 @@
package com.openisle.controller;
import com.openisle.config.CustomAccessDeniedHandler;
import com.openisle.config.SecurityConfig;
import com.openisle.service.JwtService;
import com.openisle.repository.UserRepository;
import com.openisle.service.UserVisitService;
import com.openisle.model.Role;
import com.openisle.model.User;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(StatController.class)
@AutoConfigureMockMvc
@Import({SecurityConfig.class, CustomAccessDeniedHandler.class})
class StatControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private JwtService jwtService;
@MockBean
private UserRepository userRepository;
@MockBean
private UserVisitService userVisitService;
@Test
void dauReturnsCount() throws Exception {
Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user");
User user = new User();
user.setUsername("user");
user.setPassword("p");
user.setEmail("u@example.com");
user.setRole(Role.USER);
Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user));
Mockito.when(userVisitService.countDau(Mockito.any())).thenReturn(3L);
mockMvc.perform(get("/api/stats/dau").header("Authorization", "Bearer token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.dau").value(3));
}
@Test
void dauRangeReturnsSeries() throws Exception {
Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user");
User user = new User();
user.setUsername("user");
user.setPassword("p");
user.setEmail("u@example.com");
user.setRole(Role.USER);
Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user));
java.util.Map<java.time.LocalDate, Long> map = new java.util.LinkedHashMap<>();
map.put(java.time.LocalDate.now().minusDays(1), 1L);
map.put(java.time.LocalDate.now(), 2L);
Mockito.when(userVisitService.countDauRange(Mockito.any(), Mockito.any())).thenReturn(map);
mockMvc.perform(get("/api/stats/dau-range").param("days", "2").header("Authorization", "Bearer token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].value").value(1))
.andExpect(jsonPath("$[1].value").value(2));
}
}