@@ -1,5 +1,6 @@
|
|||||||
package com.sb.web.admin.controller;
|
package com.sb.web.admin.controller;
|
||||||
|
|
||||||
|
import com.sb.web.admin.dto.PlanUpdateRequest;
|
||||||
import com.sb.web.admin.dto.RoleUpdateRequest;
|
import com.sb.web.admin.dto.RoleUpdateRequest;
|
||||||
import com.sb.web.admin.dto.StatusUpdateRequest;
|
import com.sb.web.admin.dto.StatusUpdateRequest;
|
||||||
import com.sb.web.admin.service.MemberAdminService;
|
import com.sb.web.admin.service.MemberAdminService;
|
||||||
@@ -36,6 +37,14 @@ public class MemberAdminController {
|
|||||||
return memberAdminService.updateRole(id, req.getRole(), current.getId());
|
return memberAdminService.updateRole(id, req.getRole(), current.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/plan")
|
||||||
|
public MemberAdminResponse updatePlan(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody PlanUpdateRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
return memberAdminService.updatePlan(id, req.getPlan(), current.getId());
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/status")
|
@PutMapping("/{id}/status")
|
||||||
public MemberAdminResponse updateStatus(
|
public MemberAdminResponse updateStatus(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.sb.web.admin.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 멤버십 플랜 변경 요청.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PlanUpdateRequest {
|
||||||
|
|
||||||
|
@Pattern(regexp = "FREE|PREMIUM", message = "플랜은 FREE 또는 PREMIUM 이어야 합니다.")
|
||||||
|
private String plan;
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ public class MemberAdminService {
|
|||||||
return MemberAdminResponse.from(target);
|
return MemberAdminResponse.from(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public MemberAdminResponse updatePlan(Long targetId, String plan, Long currentAdminId) {
|
||||||
|
Member target = mustFind(targetId);
|
||||||
|
memberMapper.updatePlan(targetId, plan);
|
||||||
|
target.setPlan(plan);
|
||||||
|
return MemberAdminResponse.from(target);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MemberAdminResponse updateStatus(Long targetId, String status, Long currentAdminId) {
|
public MemberAdminResponse updateStatus(Long targetId, String status, Long currentAdminId) {
|
||||||
Member target = mustFind(targetId);
|
Member target = mustFind(targetId);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class AuthSession {
|
|||||||
private String loginId;
|
private String loginId;
|
||||||
private String name;
|
private String name;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String plan;
|
||||||
private String provider;
|
private String provider;
|
||||||
private boolean rememberMe;
|
private boolean rememberMe;
|
||||||
private LocalDateTime expiresAt;
|
private LocalDateTime expiresAt;
|
||||||
@@ -34,6 +35,7 @@ public class AuthSession {
|
|||||||
.loginId(u.getLoginId())
|
.loginId(u.getLoginId())
|
||||||
.name(u.getName())
|
.name(u.getName())
|
||||||
.role(u.getRole())
|
.role(u.getRole())
|
||||||
|
.plan(u.getPlan())
|
||||||
.provider(u.getProvider())
|
.provider(u.getProvider())
|
||||||
.rememberMe(u.isRememberMe())
|
.rememberMe(u.isRememberMe())
|
||||||
.expiresAt(expiresAt)
|
.expiresAt(expiresAt)
|
||||||
@@ -46,6 +48,7 @@ public class AuthSession {
|
|||||||
.loginId(loginId)
|
.loginId(loginId)
|
||||||
.name(name)
|
.name(name)
|
||||||
.role(role)
|
.role(role)
|
||||||
|
.plan(plan)
|
||||||
.provider(provider)
|
.provider(provider)
|
||||||
.rememberMe(rememberMe)
|
.rememberMe(rememberMe)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class Member implements Serializable {
|
|||||||
private String providerId; // 소셜 제공자 측 고유 ID
|
private String providerId; // 소셜 제공자 측 고유 ID
|
||||||
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
|
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
|
||||||
private String role; // USER / ADMIN
|
private String role; // USER / ADMIN
|
||||||
|
private String plan; // FREE / PREMIUM (멤버십)
|
||||||
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class MemberAdminResponse {
|
|||||||
private String name;
|
private String name;
|
||||||
private String email;
|
private String email;
|
||||||
private String role; // USER / ADMIN
|
private String role; // USER / ADMIN
|
||||||
|
private String plan; // FREE / PREMIUM
|
||||||
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
||||||
private String provider; // LOCAL / 소셜
|
private String provider; // LOCAL / 소셜
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
@@ -29,6 +30,7 @@ public class MemberAdminResponse {
|
|||||||
.name(m.getName())
|
.name(m.getName())
|
||||||
.email(m.getEmail())
|
.email(m.getEmail())
|
||||||
.role(m.getRole())
|
.role(m.getRole())
|
||||||
|
.plan(m.getPlan())
|
||||||
.status(m.getStatus())
|
.status(m.getStatus())
|
||||||
.provider(m.getProvider())
|
.provider(m.getProvider())
|
||||||
.createdAt(m.getCreatedAt())
|
.createdAt(m.getCreatedAt())
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class MemberResponse {
|
|||||||
private String email;
|
private String email;
|
||||||
private String provider;
|
private String provider;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String plan; // FREE / PREMIUM
|
||||||
|
|
||||||
public static MemberResponse from(Member m) {
|
public static MemberResponse from(Member m) {
|
||||||
return MemberResponse.builder()
|
return MemberResponse.builder()
|
||||||
@@ -26,6 +27,7 @@ public class MemberResponse {
|
|||||||
.email(m.getEmail())
|
.email(m.getEmail())
|
||||||
.provider(m.getProvider())
|
.provider(m.getProvider())
|
||||||
.role(m.getRole())
|
.role(m.getRole())
|
||||||
|
.plan(m.getPlan())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class SessionUser implements Serializable {
|
|||||||
private String loginId;
|
private String loginId;
|
||||||
private String name;
|
private String name;
|
||||||
private String role;
|
private String role;
|
||||||
|
private String plan; // FREE / PREMIUM
|
||||||
private String provider;
|
private String provider;
|
||||||
private boolean rememberMe; // 자동 로그인 세션 여부 (슬라이딩 만료 TTL 결정용)
|
private boolean rememberMe; // 자동 로그인 세션 여부 (슬라이딩 만료 TTL 결정용)
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ public class SessionUser implements Serializable {
|
|||||||
.loginId(m.getLoginId())
|
.loginId(m.getLoginId())
|
||||||
.name(m.getName())
|
.name(m.getName())
|
||||||
.role(m.getRole())
|
.role(m.getRole())
|
||||||
|
.plan(m.getPlan())
|
||||||
.provider(m.getProvider())
|
.provider(m.getProvider())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ public interface MemberMapper {
|
|||||||
|
|
||||||
int updateRole(@Param("id") Long id, @Param("role") String role);
|
int updateRole(@Param("id") Long id, @Param("role") String role);
|
||||||
|
|
||||||
|
int updatePlan(@Param("id") Long id, @Param("plan") String plan);
|
||||||
|
|
||||||
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
|
||||||
int deleteById(@Param("id") Long id);
|
int deleteById(@Param("id") Long id);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.sb.web.auth.web;
|
||||||
|
|
||||||
|
import com.sb.web.auth.dto.SessionUser;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유료(PREMIUM) 전용 경로 보호. AuthInterceptor 가 먼저 세팅한 SessionUser 의 plan 을 검사한다.
|
||||||
|
* 프론트의 메뉴 잠금은 우회 가능하므로, 실제 API 접근은 여기서 차단한다.
|
||||||
|
* 관리자(ADMIN)는 플랜과 무관하게 모든 기능을 사용할 수 있다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PremiumInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||||
|
throws Exception {
|
||||||
|
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Object attr = request.getAttribute(AuthInterceptor.CURRENT_MEMBER);
|
||||||
|
if (attr instanceof SessionUser user
|
||||||
|
&& ("ADMIN".equals(user.getRole()) || "PREMIUM".equals(user.getPlan()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
response.getWriter().write("{\"status\":403,\"code\":\"PREMIUM_REQUIRED\",\"message\":\"유료 멤버십 전용 기능입니다.\"}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.sb.web.common.config;
|
|||||||
|
|
||||||
import com.sb.web.auth.web.AdminInterceptor;
|
import com.sb.web.auth.web.AdminInterceptor;
|
||||||
import com.sb.web.auth.web.AuthInterceptor;
|
import com.sb.web.auth.web.AuthInterceptor;
|
||||||
|
import com.sb.web.auth.web.PremiumInterceptor;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
@@ -18,6 +19,7 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
|
|
||||||
private final AuthInterceptor authInterceptor;
|
private final AuthInterceptor authInterceptor;
|
||||||
private final AdminInterceptor adminInterceptor;
|
private final AdminInterceptor adminInterceptor;
|
||||||
|
private final PremiumInterceptor premiumInterceptor;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
@@ -29,5 +31,18 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
|
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
|
||||||
registry.addInterceptor(adminInterceptor)
|
registry.addInterceptor(adminInterceptor)
|
||||||
.addPathPatterns("/api/admin/**");
|
.addPathPatterns("/api/admin/**");
|
||||||
|
// 인가: 유료(PREMIUM) 전용 경로 (authInterceptor 다음에 실행되어 plan 검사)
|
||||||
|
registry.addInterceptor(premiumInterceptor)
|
||||||
|
.addPathPatterns(
|
||||||
|
"/api/account/stats",
|
||||||
|
"/api/account/category-stats",
|
||||||
|
"/api/account/networth/trend",
|
||||||
|
"/api/account/budgets", "/api/account/budgets/**",
|
||||||
|
"/api/account/recurrings", "/api/account/recurrings/**",
|
||||||
|
"/api/account/ocr", "/api/account/ocr/**",
|
||||||
|
"/api/account/invest", "/api/account/invest/**",
|
||||||
|
"/api/account/tags", "/api/account/tags/**",
|
||||||
|
"/api/account/entries/notification",
|
||||||
|
"/api/account/restore");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_google_id (google_id);
|
|||||||
-- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함).
|
-- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함).
|
||||||
UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL;
|
UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등).
|
||||||
|
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경.
|
-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경.
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -47,6 +50,7 @@ CREATE TABLE IF NOT EXISTS auth_session (
|
|||||||
login_id VARCHAR(50) NULL,
|
login_id VARCHAR(50) NULL,
|
||||||
name VARCHAR(100) NULL,
|
name VARCHAR(100) NULL,
|
||||||
role VARCHAR(20) NULL,
|
role VARCHAR(20) NULL,
|
||||||
|
plan VARCHAR(20) NULL,
|
||||||
provider VARCHAR(20) NULL,
|
provider VARCHAR(20) NULL,
|
||||||
remember_me TINYINT(1) NOT NULL DEFAULT 0,
|
remember_me TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
@@ -54,3 +58,5 @@ CREATE TABLE IF NOT EXISTS auth_session (
|
|||||||
PRIMARY KEY (token),
|
PRIMARY KEY (token),
|
||||||
KEY idx_auth_session_expires (expires_at)
|
KEY idx_auth_session_expires (expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
-- 멤버십 플랜 백업 (기존 세션 테이블에도 멱등 추가).
|
||||||
|
ALTER TABLE auth_session ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NULL AFTER role;
|
||||||
|
|||||||
@@ -5,16 +5,16 @@
|
|||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.auth.domain.AuthSession">
|
<insert id="insert" parameterType="com.sb.web.auth.domain.AuthSession">
|
||||||
INSERT INTO auth_session
|
INSERT INTO auth_session
|
||||||
(token, member_id, login_id, name, role, provider, remember_me, expires_at, created_at)
|
(token, member_id, login_id, name, role, plan, provider, remember_me, expires_at, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(#{token}, #{memberId}, #{loginId}, #{name}, #{role}, #{provider},
|
(#{token}, #{memberId}, #{loginId}, #{name}, #{role}, #{plan}, #{provider},
|
||||||
#{rememberMe}, #{expiresAt}, NOW())
|
#{rememberMe}, #{expiresAt}, NOW())
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
expires_at = #{expiresAt}
|
expires_at = #{expiresAt}
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<select id="findByToken" resultType="com.sb.web.auth.domain.AuthSession">
|
<select id="findByToken" resultType="com.sb.web.auth.domain.AuthSession">
|
||||||
SELECT token, member_id, login_id, name, role, provider, remember_me, expires_at, created_at
|
SELECT token, member_id, login_id, name, role, plan, provider, remember_me, expires_at, created_at
|
||||||
FROM auth_session
|
FROM auth_session
|
||||||
WHERE token = #{token}
|
WHERE token = #{token}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -13,13 +13,14 @@
|
|||||||
<result property="providerId" column="provider_id"/>
|
<result property="providerId" column="provider_id"/>
|
||||||
<result property="googleId" column="google_id"/>
|
<result property="googleId" column="google_id"/>
|
||||||
<result property="role" column="role"/>
|
<result property="role" column="role"/>
|
||||||
|
<result property="plan" column="plan"/>
|
||||||
<result property="status" column="status"/>
|
<result property="status" column="status"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<result property="updatedAt" column="updated_at"/>
|
<result property="updatedAt" column="updated_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<sql id="columns">
|
<sql id="columns">
|
||||||
id, login_id, password, name, email, provider, provider_id, google_id, role, status, created_at, updated_at
|
id, login_id, password, name, email, provider, provider_id, google_id, role, plan, status, created_at, updated_at
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
<select id="findById" parameterType="long" resultMap="memberResultMap">
|
<select id="findById" parameterType="long" resultMap="memberResultMap">
|
||||||
@@ -82,6 +83,10 @@
|
|||||||
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updatePlan">
|
||||||
|
UPDATE member SET plan = #{plan}, updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
<update id="updateStatus">
|
<update id="updateStatus">
|
||||||
UPDATE member SET status = #{status}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET status = #{status}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.sb.web.common.config;
|
|||||||
|
|
||||||
import com.sb.web.auth.web.AdminInterceptor;
|
import com.sb.web.auth.web.AdminInterceptor;
|
||||||
import com.sb.web.auth.web.AuthInterceptor;
|
import com.sb.web.auth.web.AuthInterceptor;
|
||||||
|
import com.sb.web.auth.web.PremiumInterceptor;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
@@ -33,7 +34,8 @@ class WebConfigTest {
|
|||||||
when(registry.addInterceptor(any())).thenReturn(registration);
|
when(registry.addInterceptor(any())).thenReturn(registration);
|
||||||
when(registration.addPathPatterns(any(String[].class))).thenReturn(registration);
|
when(registration.addPathPatterns(any(String[].class))).thenReturn(registration);
|
||||||
|
|
||||||
new WebConfig(mock(AuthInterceptor.class), mock(AdminInterceptor.class)).addInterceptors(registry);
|
new WebConfig(mock(AuthInterceptor.class), mock(AdminInterceptor.class), mock(PremiumInterceptor.class))
|
||||||
|
.addInterceptors(registry);
|
||||||
|
|
||||||
ArgumentCaptor<String[]> captor = ArgumentCaptor.forClass(String[].class);
|
ArgumentCaptor<String[]> captor = ArgumentCaptor.forClass(String[].class);
|
||||||
verify(registration, atLeastOnce()).addPathPatterns(captor.capture());
|
verify(registration, atLeastOnce()).addPathPatterns(captor.capture());
|
||||||
@@ -47,5 +49,12 @@ class WebConfigTest {
|
|||||||
"/api/auth/password",
|
"/api/auth/password",
|
||||||
"/api/auth/verify-password",
|
"/api/auth/verify-password",
|
||||||
"/api/auth/profile");
|
"/api/auth/profile");
|
||||||
|
// 유료(PREMIUM) 전용 경로가 premiumInterceptor 에 등록되는지 회귀 가드
|
||||||
|
assertThat(allPatterns).contains(
|
||||||
|
"/api/account/stats",
|
||||||
|
"/api/account/budgets/**",
|
||||||
|
"/api/account/recurrings/**",
|
||||||
|
"/api/account/tags",
|
||||||
|
"/api/account/restore");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user