feat(mod): add status and creator tracking

- Introduce ModificationStatus enum with integer codes and a JPA converter
- Record the creating user on new modifications
- Rebuild the modification table with status, creator and audit columns
- Simplify user_role to a single-column primary key
This commit is contained in:
zihluwang committed 2026-08-20 15:18:49 +08:00
1 parent 4d9bf64ef4
commit 2936b6aece
11 files changed
+272 -146

No files matched your search

@@ -4,7 +4,9 @@ import com.onixbyte.deltaforceguide.domain.dto.ModificationBatchCreateRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse;
import com.onixbyte.deltaforceguide.domain.dto.PageResponse;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
import com.onixbyte.deltaforceguide.security.resolver.CurrentUser;
import com.onixbyte.deltaforceguide.service.ModificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -66,15 +68,21 @@ public class ModificationController {
@RequiresAuth
@Operation(description = "创建改装")
@PostMapping
public ModificationResponse create(@Valid @RequestBody ModificationRequest request) {
return modificationService.create(request);
public ModificationResponse create(
@CurrentUser User user,
@Valid @RequestBody ModificationRequest request
) {
return modificationService.create(request, user);
}
@RequiresAuth
@Operation(description = "批量创建改装")
@PostMapping("/batch")
public List<ModificationResponse> batchCreate(@Valid @RequestBody ModificationBatchCreateRequest request) {
return modificationService.batchCreate(request.modifications());
public List<ModificationResponse> batchCreate(
@CurrentUser User user,
@Valid @RequestBody ModificationBatchCreateRequest request
) {
return modificationService.batchCreate(request.modifications(), user);
}
@RequiresAuth
@@ -0,0 +1,24 @@
package com.onixbyte.deltaforceguide.domain.converter;
import com.onixbyte.deltaforceguide.enumeration.ModificationStatus;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* JPA attribute converter that maps {@link ModificationStatus} enum to/from its integer database representation.
*
* @author zihluwang
*/
@Converter
public class ModificationStatusConverter implements AttributeConverter<ModificationStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(ModificationStatus attribute) {
return attribute == null ? null : attribute.getCode();
}
@Override
public ModificationStatus convertToEntityAttribute(Integer dbData) {
return ModificationStatus.fromCode(dbData);
}
}
@@ -1,11 +1,15 @@
package com.onixbyte.deltaforceguide.domain.entity;
import com.onixbyte.deltaforceguide.domain.converter.ModificationStatusConverter;
import com.onixbyte.deltaforceguide.enumeration.ModificationStatus;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.*;
import org.hibernate.annotations.Type;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Entity representing a firearm modification or build configuration.
@@ -25,6 +29,10 @@ public class Modification {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "firearm_id", nullable = false, foreignKey = @ForeignKey(name = "fk_modification_firearm"))
private Firearm firearm;
@@ -36,11 +44,11 @@ public class Modification {
private String code;
@Type(JsonType.class)
@Column(name = "tags", columnDefinition = "jsonb")
@Column(name = "tags", columnDefinition = "jsonb", nullable = false)
private List<String> tags = new ArrayList<>();
@Type(JsonType.class)
@Column(name = "accessories", columnDefinition = "jsonb")
@Column(name = "accessories", columnDefinition = "jsonb", nullable = false)
private List<Accessory> accessories = new ArrayList<>();
@Column(name = "note", columnDefinition = "TEXT")
@@ -52,6 +60,16 @@ public class Modification {
@Column(name = "video_url", length = 512)
private String videoUrl;
@Column(name = "status", nullable = false)
@Convert(converter = ModificationStatusConverter.class)
private ModificationStatus status = ModificationStatus.DRAFT;
@Column(name = "create_by")
private Long createBy;
@Column(name = "create_time", nullable = false)
private OffsetDateTime createTime;
public Long getId() {
return id;
}
@@ -132,6 +150,38 @@ public class Modification {
this.videoUrl = videoUrl;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public Long getCreateBy() {
return createBy;
}
public void setCreateBy(Long createBy) {
this.createBy = createBy;
}
public OffsetDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(OffsetDateTime createTime) {
this.createTime = createTime;
}
public ModificationStatus getStatus() {
return status;
}
public void setStatus(ModificationStatus status) {
this.status = status;
}
public static Builder builder() {
return new Builder();
}
@@ -139,6 +189,7 @@ public class Modification {
public static class Builder {
private Long id;
private User user;
private Firearm firearm;
private String name;
private String code;
@@ -147,12 +198,20 @@ public class Modification {
private String note;
private String author;
private String videoUrl;
private Long createBy;
private OffsetDateTime createTime;
private ModificationStatus status;
public Builder id(Long id) {
this.id = id;
return this;
}
public Builder user(User user) {
this.user = user;
return this;
}
public Builder firearm(Firearm firearm) {
this.firearm = firearm;
return this;
@@ -193,17 +252,36 @@ public class Modification {
return this;
}
public Builder createBy(Long createBy) {
this.createBy = createBy;
return this;
}
public Builder createTime(OffsetDateTime createTime) {
this.createTime = createTime;
return this;
}
public Builder status(ModificationStatus status) {
this.status = status;
return this;
}
public Modification build() {
Modification modification = new Modification();
modification.id = this.id;
modification.user = this.user;
modification.firearm = this.firearm;
modification.name = this.name;
modification.code = this.code;
modification.tags = this.tags == null ? new ArrayList<>() : this.tags;
modification.accessories = this.accessories == null ? new ArrayList<>() : this.accessories;
modification.tags = Optional.ofNullable(this.tags).orElseGet(ArrayList::new);
modification.accessories = Optional.ofNullable(this.accessories).orElseGet(ArrayList::new);
modification.note = this.note;
modification.author = this.author;
modification.videoUrl = this.videoUrl;
modification.createBy = this.createBy;
modification.createTime = Optional.ofNullable(this.createTime).orElseGet(OffsetDateTime::now);
modification.status = Optional.ofNullable(this.status).orElse(ModificationStatus.DRAFT);
return modification;
}
}
@@ -1,12 +1,10 @@
package com.onixbyte.deltaforceguide.domain.entity;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
@@ -14,6 +12,8 @@ import jakarta.persistence.Table;
/**
* Entity representing a role assignment linking a user to a security role.
* <p>
* A user owns at most one role assignment, so the user ID is the primary key.
*
* @author zihluwang
*/
@@ -21,24 +21,24 @@ import jakarta.persistence.Table;
@Table(name = "user_role")
public class UserRole {
@EmbeddedId
@AttributeOverrides({
@AttributeOverride(name = "userId", column = @Column(name = "user_id")),
@AttributeOverride(name = "role", column = @Column(name = "role"))
})
private UserRoleId id = new UserRoleId();
@Id
@Column(name = "user_id")
private Long userId;
@MapsId("userId")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_role_user"))
private User user;
public UserRoleId getId() {
return id;
@Column(name = "role", nullable = false, length = 32)
private String role;
public Long getUserId() {
return userId;
}
public void setId(UserRoleId id) {
this.id = id;
public void setUserId(Long userId) {
this.userId = userId;
}
public User getUser() {
@@ -47,32 +47,17 @@ public class UserRole {
public void setUser(User user) {
this.user = user;
if (this.id == null) {
this.id = new UserRoleId();
if (user != null) {
this.userId = user.getId();
}
this.id.setUserId(user == null ? null : user.getId());
}
public Long getUserId() {
return id == null ? null : id.getUserId();
}
public void setUserId(Long userId) {
if (this.id == null) {
this.id = new UserRoleId();
}
this.id.setUserId(userId);
}
public String getRole() {
return id == null ? null : id.getRole();
return role;
}
public void setRole(String role) {
if (this.id == null) {
this.id = new UserRoleId();
}
this.id.setRole(role);
this.role = role;
}
public static Builder builder() {
@@ -81,16 +66,10 @@ public class UserRole {
public static class Builder {
private UserRoleId id;
private User user;
private Long userId;
private String role;
public Builder id(UserRoleId id) {
this.id = id;
return this;
}
public Builder user(User user) {
this.user = user;
return this;
@@ -108,16 +87,13 @@ public class UserRole {
public UserRole build() {
UserRole userRole = new UserRole();
userRole.id = this.id == null ? new UserRoleId() : this.id;
userRole.user = this.user;
userRole.role = this.role;
if (this.user != null) {
userRole.id.setUserId(this.user.getId());
userRole.userId = this.user.getId();
}
if (this.userId != null) {
userRole.id.setUserId(this.userId);
}
if (this.role != null) {
userRole.id.setRole(this.role);
userRole.userId = this.userId;
}
return userRole;
}
@@ -1,79 +0,0 @@
package com.onixbyte.deltaforceguide.domain.entity;
import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.util.Objects;
/**
* Composite key for the UserRole entity, combining user ID and role name.
*
* @author zihluwang
*/
@Embeddable
public class UserRoleId implements Serializable {
private Long userId;
private String role;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
UserRoleId that = (UserRoleId) o;
return Objects.equals(userId, that.userId) && Objects.equals(role, that.role);
}
@Override
public int hashCode() {
return Objects.hash(userId, role);
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long userId;
private String role;
public Builder userId(Long userId) {
this.userId = userId;
return this;
}
public Builder role(String role) {
this.role = role;
return this;
}
public UserRoleId build() {
UserRoleId id = new UserRoleId();
id.userId = this.userId;
id.role = this.role;
return id;
}
}
}
@@ -0,0 +1,44 @@
package com.onixbyte.deltaforceguide.enumeration;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Enumeration of modification statuses.
* <p>
* Each status is associated with an integer code used for database persistence.
*
* @author zihluwang
*/
public enum ModificationStatus {
DRAFT(0),
PENDING_APPROVAL(1),
PUBLISHED(2);
private final int code;
ModificationStatus(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static final Map<Integer, ModificationStatus> CODE_MAP = Arrays.stream(values())
.collect(Collectors.toMap(ModificationStatus::getCode, Function.identity()));
/**
* Resolve a ModificationStatus from its integer code.
*
* @param code the integer code, may be null
* @return the corresponding ModificationStatus, or null if the code is null
* @throws IllegalArgumentException if the code does not match any known status
*/
public static ModificationStatus fromCode(Integer code) {
return CODE_MAP.get(code);
}
}
@@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory;
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.domain.entity.Modification;
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse;
@@ -36,16 +37,16 @@ public class ModificationManager {
}
@Transactional
public ModificationResponse create(ModificationRequest request) {
public ModificationResponse create(ModificationRequest request, User user) {
var firearm = firearmRepository.findById(request.firearmId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"Firearm not found: " + request.firearmId()));
var modification = toEntity(request, firearm);
var modification = toEntity(request, firearm, user);
return ModificationResponse.from(modificationRepository.save(modification));
}
@Transactional
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests) {
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests, User user) {
var firearmIds = requests.stream()
.map(ModificationRequest::firearmId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
@@ -63,7 +64,7 @@ public class ModificationManager {
}
var modifications = requests.stream()
.map(req -> toEntity(req, firearmMap.get(req.firearmId())))
.map(req -> toEntity(req, firearmMap.get(req.firearmId()), user))
.toList();
return modificationRepository.saveAll(modifications)
.stream()
@@ -86,7 +87,7 @@ public class ModificationManager {
return matches.getFirst().getId();
}
private Modification toEntity(ModificationRequest request, Firearm firearm) {
private Modification toEntity(ModificationRequest request, Firearm firearm, User user) {
return Modification.builder()
.firearm(firearm)
.name(request.name())
@@ -96,6 +97,7 @@ public class ModificationManager {
.note(request.note())
.author(request.author())
.videoUrl(request.videoUrl())
.createBy(user == null ? null : user.getId())
.build();
}
@@ -1,7 +1,6 @@
package com.onixbyte.deltaforceguide.repository;
import com.onixbyte.deltaforceguide.domain.entity.UserRole;
import com.onixbyte.deltaforceguide.domain.entity.UserRoleId;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -14,7 +13,7 @@ import java.util.List;
* @author zihluwang
*/
@Repository
public interface UserRoleRepository extends JpaRepository<UserRole, UserRoleId>, JpaSpecificationExecutor<UserRole> {
public interface UserRoleRepository extends JpaRepository<UserRole, Long>, JpaSpecificationExecutor<UserRole> {
/**
* Find all roles assigned to a given user.
@@ -41,7 +40,7 @@ public interface UserRoleRepository extends JpaRepository<UserRole, UserRoleId>,
select count(ur) > 0
from UserRole ur
where ur.user.id = :userId
and ur.id.role = :role
and ur.role = :role
""")
boolean existsByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role);
@@ -55,7 +54,7 @@ public interface UserRoleRepository extends JpaRepository<UserRole, UserRoleId>,
@Query("""
delete from UserRole ur
where ur.user.id = :userId
and ur.id.role = :role
and ur.role = :role
""")
void deleteByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role);
@@ -8,6 +8,7 @@ import com.onixbyte.deltaforceguide.domain.dto.TuningRequest;
import com.onixbyte.deltaforceguide.domain.entity.Accessory;
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.domain.entity.Modification;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
import com.onixbyte.deltaforceguide.manager.ModificationManager;
import com.onixbyte.deltaforceguide.repository.FirearmRepository;
@@ -104,20 +105,22 @@ public class ModificationService {
* Creates a new modification for a given firearm.
*
* @param request the modification creation request
* @param user the authenticated user creating the modification
* @return the created modification response
*/
public ModificationResponse create(ModificationRequest request) {
return modificationManager.create(request);
public ModificationResponse create(ModificationRequest request, User user) {
return modificationManager.create(request, user);
}
/**
* Creates multiple modifications in a single batch operation.
*
* @param requests list of modification creation requests
* @param user the authenticated user creating the modifications
* @return list of created modification responses
*/
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests) {
return modificationManager.batchCreate(requests);
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests, User user) {
return modificationManager.batchCreate(requests, user);
}
/**
@@ -88,7 +88,7 @@ public class WebhookService {
private void processSingle(Long issueNumber, Map<String, Object> data) {
var request = mapToRequest(data);
log.info("Creating modification from issue #{}: name={}", issueNumber, request.name());
modificationManager.create(request);
modificationManager.create(request, null);
}
@SuppressWarnings("unchecked")
@@ -102,7 +102,7 @@ public class WebhookService {
.map(this::mapToRequest)
.toList();
log.info("Batch creating {} modifications from issue #{}", requests.size(), issueNumber);
modificationManager.batchCreate(requests);
modificationManager.batchCreate(requests, null);
}
private ModificationRequest mapToRequest(Map<String, Object> data) {
@@ -0,0 +1,71 @@
BEGIN;
CREATE TABLE IF NOT EXISTS modification_new
(
id BIGSERIAL NOT NULL PRIMARY KEY,
user_id BIGINT NULL REFERENCES app_user (id),
firearm_id BIGINT NOT NULL REFERENCES firearm (id),
name VARCHAR(64) NOT NULL,
code VARCHAR(64) NOT NULL,
tags JSONB NOT NULL DEFAULT '[]'::JSONB,
note TEXT NULL,
author VARCHAR(64) NULL,
video_url VARCHAR(512) NULL,
accessories JSONB NOT NULL DEFAULT '[]'::JSONB,
status INTEGER NOT NULL DEFAULT 0,
create_by BIGINT NULL,
create_time TIMESTAMPTZ NOT NULL,
CONSTRAINT tags_must_be_string_array CHECK (
jsonb_typeof(tags) = 'array' AND
NOT jsonb_path_exists(tags, '$[*] ? (@.type() != "string")')
)
);
DO
$$
DECLARE
v_super_user_id BIGINT;
BEGIN
-- Added explicit table reference and LIMIT 1 to prevent multiple-row errors
SELECT app_user.id
INTO v_super_user_id
FROM app_user
LEFT JOIN user_role ur ON app_user.id = ur.user_id
WHERE ur.role = 'SUPER_USER'
LIMIT 1;
-- Explicitly check for NULL rather than evaluating the integer
IF v_super_user_id IS NOT NULL THEN
INSERT INTO modification_new(id, user_id, firearm_id, name, code, tags, note, author,
video_url, accessories, status, create_by, create_time)
SELECT m.id,
NULL,
m.firearm_id,
m.name,
m.code,
COALESCE(m.tags, '[]'::JSONB),
m.note,
m.author,
m.video_url,
COALESCE(m.accessories, '[]'::JSONB),
2,
v_super_user_id,
now()
FROM modification m;
ELSE
-- Fixed RAISE NOTICE parameter formatting
RAISE NOTICE 'Super user not found. Please execute the manual SQL commands after adding a super user.';
END IF;
END
$$;
ALTER INDEX idx_modification_firearm_id RENAME TO idx_modification_lagacy_firearm_id;
CREATE INDEX idx_modification_firearm_id ON modification_new (firearm_id);
ALTER TABLE modification
RENAME TO modification_legacy;
ALTER TABLE modification_new
RENAME TO modification;
COMMIT;