diff --git a/src/main/java/com/gameplatform/server/controller/UserController.java b/src/main/java/com/gameplatform/server/controller/UserController.java index 761f519..5230b5d 100644 --- a/src/main/java/com/gameplatform/server/controller/UserController.java +++ b/src/main/java/com/gameplatform/server/controller/UserController.java @@ -40,5 +40,9 @@ public class UserController { .filter(Boolean::booleanValue) .then(); } -} + @PutMapping("/{id}") + public Mono update(@PathVariable Long id, @Valid @RequestBody User user) { + return userService.update(id, user); + } +} diff --git a/src/main/java/com/gameplatform/server/controller/admin/AccountController.java b/src/main/java/com/gameplatform/server/controller/admin/AccountController.java new file mode 100644 index 0000000..4ebbae9 --- /dev/null +++ b/src/main/java/com/gameplatform/server/controller/admin/AccountController.java @@ -0,0 +1,66 @@ +package com.gameplatform.server.controller.admin; + +import com.gameplatform.server.model.dto.account.*; +import com.gameplatform.server.model.dto.common.PageResult; +import com.gameplatform.server.service.account.AccountService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/admin/accounts") +public class AccountController { + private final AccountService accountService; + + public AccountController(AccountService accountService) { + this.accountService = accountService; + } + + @GetMapping + public Mono> list( + @RequestParam(value = "userType", required = false) String userType, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "role", required = false) String role, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size + ) { + return accountService.list(userType, status, role, keyword, page, size); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Mono create(@Valid @RequestBody AccountCreateRequest req) { + return accountService.create(req); + } + + @GetMapping("/{id}") + public Mono detail(@PathVariable Long id) { + return accountService.get(id); + } + + @PatchMapping("/{id}") + public Mono update(@PathVariable Long id, @Valid @RequestBody AccountUpdateRequest req) { + return accountService.update(id, req); + } + + @PostMapping("/{id}/enable") + @ResponseStatus(HttpStatus.NO_CONTENT) + public Mono enable(@PathVariable Long id) { + return accountService.setStatus(id, "ENABLED").then(); + } + + @PostMapping("/{id}/disable") + @ResponseStatus(HttpStatus.NO_CONTENT) + public Mono disable(@PathVariable Long id) { + return accountService.setStatus(id, "DISABLED").then(); + } + + @PostMapping("/{id}/reset-password") + @ResponseStatus(HttpStatus.NO_CONTENT) + public Mono resetPassword(@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest req) { + return accountService.resetPassword(id, req.getNewPassword(), Boolean.TRUE.equals(req.getForceLogout())); + } +} + diff --git a/src/main/java/com/gameplatform/server/mapper/UserMapper.java b/src/main/java/com/gameplatform/server/mapper/UserMapper.java index ec052d5..a11afed 100644 --- a/src/main/java/com/gameplatform/server/mapper/UserMapper.java +++ b/src/main/java/com/gameplatform/server/mapper/UserMapper.java @@ -13,5 +13,6 @@ public interface UserMapper { int insert(User user); int deleteById(@Param("id") Long id); -} + int update(User user); +} diff --git a/src/main/java/com/gameplatform/server/mapper/account/UserAccountMapper.java b/src/main/java/com/gameplatform/server/mapper/account/UserAccountMapper.java index 4fd66ed..e8a4528 100644 --- a/src/main/java/com/gameplatform/server/mapper/account/UserAccountMapper.java +++ b/src/main/java/com/gameplatform/server/mapper/account/UserAccountMapper.java @@ -7,4 +7,21 @@ public interface UserAccountMapper { UserAccount findByUsernameAndType(@Param("username") String username, @Param("userType") String userType); UserAccount findByUsername(@Param("username") String username); + UserAccount findById(@Param("id") Long id); + int insert(UserAccount account); + int update(UserAccount account); + int setStatus(@Param("id") Long id, @Param("status") String status); + int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash); + + long countByFilter(@Param("userType") String userType, + @Param("status") String status, + @Param("role") String role, + @Param("keyword") String keyword); + + java.util.List listByFilter(@Param("userType") String userType, + @Param("status") String status, + @Param("role") String role, + @Param("keyword") String keyword, + @Param("size") int size, + @Param("offset") int offset); } diff --git a/src/main/java/com/gameplatform/server/model/dto/account/AccountCreateRequest.java b/src/main/java/com/gameplatform/server/model/dto/account/AccountCreateRequest.java new file mode 100644 index 0000000..5faf5ed --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/dto/account/AccountCreateRequest.java @@ -0,0 +1,36 @@ +package com.gameplatform.server.model.dto.account; + +import jakarta.validation.constraints.*; + +public class AccountCreateRequest { + @NotBlank + private String userType; // ADMIN | AGENT + @NotBlank + @Size(min = 3, max = 64) + private String username; + @Size(max = 100) + private String displayName; + private String role; // for ADMIN: SUPER | ADMIN + private String status = "ENABLED"; // ENABLED | DISABLED + @NotBlank + @Size(min = 6, max = 128) + private String initialPassword; + @Min(0) + private Long pointsBalance = 0L; // for AGENT + + public String getUserType() { return userType; } + public void setUserType(String userType) { this.userType = userType; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getInitialPassword() { return initialPassword; } + public void setInitialPassword(String initialPassword) { this.initialPassword = initialPassword; } + public Long getPointsBalance() { return pointsBalance; } + public void setPointsBalance(Long pointsBalance) { this.pointsBalance = pointsBalance; } +} + diff --git a/src/main/java/com/gameplatform/server/model/dto/account/AccountResponse.java b/src/main/java/com/gameplatform/server/model/dto/account/AccountResponse.java new file mode 100644 index 0000000..5696a99 --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/dto/account/AccountResponse.java @@ -0,0 +1,35 @@ +package com.gameplatform.server.model.dto.account; + +import java.time.LocalDateTime; + +public class AccountResponse { + private Long id; + private String userType; + private String username; + private String displayName; + private String role; + private String status; + private Long pointsBalance; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getUserType() { return userType; } + public void setUserType(String userType) { this.userType = userType; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Long getPointsBalance() { return pointsBalance; } + public void setPointsBalance(Long pointsBalance) { this.pointsBalance = pointsBalance; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} + diff --git a/src/main/java/com/gameplatform/server/model/dto/account/AccountUpdateRequest.java b/src/main/java/com/gameplatform/server/model/dto/account/AccountUpdateRequest.java new file mode 100644 index 0000000..8176cc0 --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/dto/account/AccountUpdateRequest.java @@ -0,0 +1,18 @@ +package com.gameplatform.server.model.dto.account; + +import jakarta.validation.constraints.Size; + +public class AccountUpdateRequest { + @Size(max = 100) + private String displayName; + private String role; // SUPER | ADMIN (only for ADMIN) + private String status; // ENABLED | DISABLED + + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } +} + diff --git a/src/main/java/com/gameplatform/server/model/dto/account/ResetPasswordRequest.java b/src/main/java/com/gameplatform/server/model/dto/account/ResetPasswordRequest.java new file mode 100644 index 0000000..85854c8 --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/dto/account/ResetPasswordRequest.java @@ -0,0 +1,17 @@ +package com.gameplatform.server.model.dto.account; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class ResetPasswordRequest { + @NotBlank + @Size(min = 6, max = 128) + private String newPassword; + private Boolean forceLogout = Boolean.TRUE; + + public String getNewPassword() { return newPassword; } + public void setNewPassword(String newPassword) { this.newPassword = newPassword; } + public Boolean getForceLogout() { return forceLogout; } + public void setForceLogout(Boolean forceLogout) { this.forceLogout = forceLogout; } +} + diff --git a/src/main/java/com/gameplatform/server/model/dto/common/PageResult.java b/src/main/java/com/gameplatform/server/model/dto/common/PageResult.java new file mode 100644 index 0000000..387978e --- /dev/null +++ b/src/main/java/com/gameplatform/server/model/dto/common/PageResult.java @@ -0,0 +1,29 @@ +package com.gameplatform.server.model.dto.common; + +import java.util.List; + +public class PageResult { + private List items; + private long total; + private int page; + private int size; + + public PageResult() {} + + public PageResult(List items, long total, int page, int size) { + this.items = items; + this.total = total; + this.page = page; + this.size = size; + } + + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + public long getTotal() { return total; } + public void setTotal(long total) { this.total = total; } + public int getPage() { return page; } + public void setPage(int page) { this.page = page; } + public int getSize() { return size; } + public void setSize(int size) { this.size = size; } +} + diff --git a/src/main/java/com/gameplatform/server/service/UserService.java b/src/main/java/com/gameplatform/server/service/UserService.java index ff67dcb..beb4f85 100644 --- a/src/main/java/com/gameplatform/server/service/UserService.java +++ b/src/main/java/com/gameplatform/server/service/UserService.java @@ -41,5 +41,14 @@ public class UserService { return Mono.fromCallable(() -> userMapper.deleteById(id) > 0) .subscribeOn(Schedulers.boundedElastic()); } -} + public Mono update(Long id, User user) { + return Mono.fromCallable(() -> { + user.setId(id); + int n = userMapper.update(user); + return n; + }) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(n -> n > 0 ? getById(id) : Mono.empty()); + } +} diff --git a/src/main/java/com/gameplatform/server/service/account/AccountService.java b/src/main/java/com/gameplatform/server/service/account/AccountService.java new file mode 100644 index 0000000..b80f322 --- /dev/null +++ b/src/main/java/com/gameplatform/server/service/account/AccountService.java @@ -0,0 +1,131 @@ +package com.gameplatform.server.service.account; + +import com.gameplatform.server.mapper.account.UserAccountMapper; +import com.gameplatform.server.model.dto.account.AccountCreateRequest; +import com.gameplatform.server.model.dto.account.AccountResponse; +import com.gameplatform.server.model.dto.account.AccountUpdateRequest; +import com.gameplatform.server.model.dto.common.PageResult; +import com.gameplatform.server.model.entity.account.UserAccount; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class AccountService { + private final UserAccountMapper mapper; + private final PasswordEncoder passwordEncoder; + + public AccountService(UserAccountMapper mapper, PasswordEncoder passwordEncoder) { + this.mapper = mapper; + this.passwordEncoder = passwordEncoder; + } + + public Mono> list(String userType, String status, String role, String keyword, + Integer page, Integer size) { + int p = (page == null || page < 1) ? 1 : page; + int s = (size == null || size < 1 || size > 200) ? 20 : size; + int offset = (p - 1) * s; + return Mono.fromCallable(() -> { + long total = mapper.countByFilter(userType, status, role, keyword); + List list = mapper.listByFilter(userType, status, role, keyword, s, offset); + List items = list.stream().map(this::toResp).collect(Collectors.toList()); + return new PageResult<>(items, total, p, s); + }) + .subscribeOn(Schedulers.boundedElastic()); + } + + @Transactional + public Mono create(AccountCreateRequest req) { + return Mono.fromCallable(() -> { + // basic normalize + String type = req.getUserType().toUpperCase(); + if (!type.equals("ADMIN") && !type.equals("AGENT")) { + throw new IllegalArgumentException("userType 只能是 ADMIN 或 AGENT"); + } + if (mapper.findByUsername(req.getUsername()) != null) { + throw new IllegalArgumentException("用户名已存在"); + } + + UserAccount acc = new UserAccount(); + acc.setUserType(type); + acc.setUsername(req.getUsername()); + acc.setDisplayName(req.getDisplayName()); + acc.setStatus(req.getStatus() == null ? "ENABLED" : req.getStatus()); + if ("ADMIN".equals(type)) { + acc.setRole(req.getRole() == null ? "ADMIN" : req.getRole()); + acc.setPointsBalance(0L); + } else { + acc.setRole(null); + acc.setPointsBalance(req.getPointsBalance() == null ? 0L : req.getPointsBalance()); + } + acc.setPasswordHash(passwordEncoder.encode(req.getInitialPassword())); + mapper.insert(acc); + return toResp(acc); + }) + .subscribeOn(Schedulers.boundedElastic()); + } + + public Mono get(Long id) { + return Mono.fromCallable(() -> mapper.findById(id)) + .subscribeOn(Schedulers.boundedElastic()) + .map(this::toResp); + } + + @Transactional + public Mono update(Long id, AccountUpdateRequest req) { + return Mono.fromCallable(() -> { + UserAccount db = mapper.findById(id); + if (db == null) return null; + UserAccount patch = new UserAccount(); + patch.setId(id); + patch.setDisplayName(req.getDisplayName()); + // Only ADMIN account may set role; AGENT's role must remain null + if ("ADMIN".equalsIgnoreCase(db.getUserType())) { + patch.setRole(req.getRole()); + } + patch.setStatus(req.getStatus()); + mapper.update(patch); + return mapper.findById(id); + }) + .subscribeOn(Schedulers.boundedElastic()) + .map(this::toResp); + } + + @Transactional + public Mono setStatus(Long id, String status) { + return Mono.fromCallable(() -> mapper.setStatus(id, status) > 0) + .subscribeOn(Schedulers.boundedElastic()); + } + + @Transactional + public Mono resetPassword(Long id, String newPassword, boolean forceLogout) { + return Mono.fromRunnable(() -> { + String hash = passwordEncoder.encode(newPassword); + mapper.updatePassword(id, hash); + // TODO: forceLogout 可结合 token 版本/黑名单实现(后续扩展) + }) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + } + + private AccountResponse toResp(UserAccount a) { + if (a == null) return null; + AccountResponse r = new AccountResponse(); + r.setId(a.getId()); + r.setUserType(a.getUserType()); + r.setUsername(a.getUsername()); + r.setDisplayName(a.getDisplayName()); + r.setRole(a.getRole()); + r.setStatus(a.getStatus()); + r.setPointsBalance(a.getPointsBalance()); + r.setCreatedAt(a.getCreatedAt()); + r.setUpdatedAt(a.getUpdatedAt()); + return r; + } +} + diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml index b7ebdf1..beaa840 100644 --- a/src/main/resources/mapper/UserMapper.xml +++ b/src/main/resources/mapper/UserMapper.xml @@ -36,4 +36,11 @@ DELETE FROM users WHERE id = #{id} + + UPDATE users + SET username = #{username}, + email = #{email} + WHERE id = #{id} + + diff --git a/src/main/resources/mapper/account/UserAccountMapper.xml b/src/main/resources/mapper/account/UserAccountMapper.xml index 3736788..578d906 100644 --- a/src/main/resources/mapper/account/UserAccountMapper.xml +++ b/src/main/resources/mapper/account/UserAccountMapper.xml @@ -28,4 +28,61 @@ WHERE username = #{username} LIMIT 1 + + + + + INSERT INTO user_account (user_type, username, display_name, password_hash, role, status, points_balance) + VALUES (#{userType}, #{username}, #{displayName}, #{passwordHash}, #{role}, #{status}, #{pointsBalance}) + + + + UPDATE user_account + + display_name = #{displayName}, + role = #{role}, + status = #{status}, + + WHERE id = #{id} + + + + UPDATE user_account SET status = #{status} WHERE id = #{id} + + + + UPDATE user_account SET password_hash = #{passwordHash} WHERE id = #{id} + + + + + diff --git a/target/classes/com/gameplatform/server/config/CorsConfig.class b/target/classes/com/gameplatform/server/config/CorsConfig.class new file mode 100644 index 0000000..9b5d160 Binary files /dev/null and b/target/classes/com/gameplatform/server/config/CorsConfig.class differ diff --git a/target/classes/com/gameplatform/server/exception/GlobalExceptionHandler$2.class b/target/classes/com/gameplatform/server/exception/GlobalExceptionHandler$2.class new file mode 100644 index 0000000..88214a0 Binary files /dev/null and b/target/classes/com/gameplatform/server/exception/GlobalExceptionHandler$2.class differ