- 数据库:在adm_charge_item表添加SourceBillNo字段 - 后端实体类:更新ChargeItem.java添加SourceBillNo字段 - 前端组件:创建手术计费界面(基于门诊划价界面) - 后端API:扩展PrePrePaymentDto支持手术计费标识 - 后端Service:扩展getChargeItems方法支持手术计费过滤 - 门诊手术安排界面:添加【计费】按钮 注意事项: - 需要手动执行SQL脚本:openhis-server-new/sql/add_source_bill_no_to_adm_charge_item.sql - 术后一站式结算功能待后续开发
382 lines
11 KiB
Markdown
382 lines
11 KiB
Markdown
# Data Access - Spring Data JPA
|
|
|
|
## JPA Entity Pattern
|
|
|
|
```java
|
|
@Entity
|
|
@Table(name = "users", indexes = {
|
|
@Index(name = "idx_email", columnList = "email", unique = true),
|
|
@Index(name = "idx_username", columnList = "username")
|
|
})
|
|
@EntityListeners(AuditingEntityListener.class)
|
|
@Getter @Setter
|
|
@NoArgsConstructor
|
|
@AllArgsConstructor
|
|
@Builder
|
|
public class User {
|
|
|
|
@Id
|
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
private Long id;
|
|
|
|
@Column(nullable = false, unique = true, length = 100)
|
|
private String email;
|
|
|
|
@Column(nullable = false, length = 100)
|
|
private String password;
|
|
|
|
@Column(nullable = false, unique = true, length = 50)
|
|
private String username;
|
|
|
|
@Column(nullable = false)
|
|
@Builder.Default
|
|
private Boolean active = true;
|
|
|
|
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
|
|
@Builder.Default
|
|
private List<Address> addresses = new ArrayList<>();
|
|
|
|
@ManyToMany
|
|
@JoinTable(
|
|
name = "user_roles",
|
|
joinColumns = @JoinColumn(name = "user_id"),
|
|
inverseJoinColumns = @JoinColumn(name = "role_id")
|
|
)
|
|
@Builder.Default
|
|
private Set<Role> roles = new HashSet<>();
|
|
|
|
@CreatedDate
|
|
@Column(nullable = false, updatable = false)
|
|
private LocalDateTime createdAt;
|
|
|
|
@LastModifiedDate
|
|
@Column(nullable = false)
|
|
private LocalDateTime updatedAt;
|
|
|
|
@Version
|
|
private Long version;
|
|
|
|
// Helper methods for bidirectional relationships
|
|
public void addAddress(Address address) {
|
|
addresses.add(address);
|
|
address.setUser(this);
|
|
}
|
|
|
|
public void removeAddress(Address address) {
|
|
addresses.remove(address);
|
|
address.setUser(null);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Spring Data JPA Repository
|
|
|
|
```java
|
|
@Repository
|
|
public interface UserRepository extends JpaRepository<User, Long>,
|
|
JpaSpecificationExecutor<User> {
|
|
|
|
Optional<User> findByEmail(String email);
|
|
|
|
Optional<User> findByUsername(String username);
|
|
|
|
boolean existsByEmail(String email);
|
|
|
|
boolean existsByUsername(String username);
|
|
|
|
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
|
|
Optional<User> findByEmailWithRoles(@Param("email") String email);
|
|
|
|
@Query("SELECT u FROM User u WHERE u.active = true AND u.createdAt >= :since")
|
|
List<User> findActiveUsersSince(@Param("since") LocalDateTime since);
|
|
|
|
@Modifying
|
|
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :threshold")
|
|
int deactivateInactiveUsers(@Param("threshold") LocalDateTime threshold);
|
|
|
|
// Projection for read-only DTOs
|
|
@Query("SELECT new com.example.dto.UserSummary(u.id, u.username, u.email) " +
|
|
"FROM User u WHERE u.active = true")
|
|
List<UserSummary> findAllActiveSummaries();
|
|
}
|
|
```
|
|
|
|
## Repository with Specifications
|
|
|
|
```java
|
|
public class UserSpecifications {
|
|
|
|
public static Specification<User> hasEmail(String email) {
|
|
return (root, query, cb) ->
|
|
email == null ? null : cb.equal(root.get("email"), email);
|
|
}
|
|
|
|
public static Specification<User> isActive() {
|
|
return (root, query, cb) -> cb.isTrue(root.get("active"));
|
|
}
|
|
|
|
public static Specification<User> createdAfter(LocalDateTime date) {
|
|
return (root, query, cb) ->
|
|
date == null ? null : cb.greaterThanOrEqualTo(root.get("createdAt"), date);
|
|
}
|
|
|
|
public static Specification<User> hasRole(String roleName) {
|
|
return (root, query, cb) -> {
|
|
Join<User, Role> roles = root.join("roles", JoinType.INNER);
|
|
return cb.equal(roles.get("name"), roleName);
|
|
};
|
|
}
|
|
}
|
|
|
|
// Usage in service
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class UserService {
|
|
private final UserRepository userRepository;
|
|
|
|
public Page<User> searchUsers(UserSearchCriteria criteria, Pageable pageable) {
|
|
Specification<User> spec = Specification
|
|
.where(UserSpecifications.hasEmail(criteria.email()))
|
|
.and(UserSpecifications.isActive())
|
|
.and(UserSpecifications.createdAfter(criteria.createdAfter()));
|
|
|
|
return userRepository.findAll(spec, pageable);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Transaction Management
|
|
|
|
```java
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Transactional(readOnly = true)
|
|
public class OrderService {
|
|
private final OrderRepository orderRepository;
|
|
private final PaymentService paymentService;
|
|
private final InventoryService inventoryService;
|
|
private final NotificationService notificationService;
|
|
|
|
@Transactional
|
|
public Order createOrder(OrderCreateRequest request) {
|
|
// All operations in single transaction
|
|
Order order = Order.builder()
|
|
.customerId(request.customerId())
|
|
.status(OrderStatus.PENDING)
|
|
.build();
|
|
|
|
request.items().forEach(item -> {
|
|
inventoryService.reserveStock(item.productId(), item.quantity());
|
|
order.addItem(item);
|
|
});
|
|
|
|
order = orderRepository.save(order);
|
|
|
|
try {
|
|
paymentService.processPayment(order);
|
|
order.setStatus(OrderStatus.PAID);
|
|
} catch (PaymentException e) {
|
|
order.setStatus(OrderStatus.PAYMENT_FAILED);
|
|
throw e; // Transaction will rollback
|
|
}
|
|
|
|
return orderRepository.save(order);
|
|
}
|
|
|
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
public void logOrderEvent(Long orderId, String event) {
|
|
// Separate transaction - will commit even if parent rolls back
|
|
OrderEvent orderEvent = new OrderEvent(orderId, event);
|
|
orderEventRepository.save(orderEvent);
|
|
}
|
|
|
|
@Transactional(noRollbackFor = NotificationException.class)
|
|
public void completeOrder(Long orderId) {
|
|
Order order = orderRepository.findById(orderId)
|
|
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
|
|
|
|
order.setStatus(OrderStatus.COMPLETED);
|
|
orderRepository.save(order);
|
|
|
|
// Won't rollback transaction if notification fails
|
|
try {
|
|
notificationService.sendCompletionEmail(order);
|
|
} catch (NotificationException e) {
|
|
log.error("Failed to send notification for order {}", orderId, e);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Auditing Configuration
|
|
|
|
```java
|
|
@Configuration
|
|
@EnableJpaAuditing
|
|
public class JpaAuditingConfig {
|
|
|
|
@Bean
|
|
public AuditorAware<String> auditorProvider() {
|
|
return () -> {
|
|
Authentication authentication = SecurityContextHolder
|
|
.getContext()
|
|
.getAuthentication();
|
|
|
|
if (authentication == null || !authentication.isAuthenticated()) {
|
|
return Optional.of("system");
|
|
}
|
|
|
|
return Optional.of(authentication.getName());
|
|
};
|
|
}
|
|
}
|
|
|
|
@MappedSuperclass
|
|
@EntityListeners(AuditingEntityListener.class)
|
|
@Getter @Setter
|
|
public abstract class AuditableEntity {
|
|
|
|
@CreatedDate
|
|
@Column(nullable = false, updatable = false)
|
|
private LocalDateTime createdAt;
|
|
|
|
@CreatedBy
|
|
@Column(nullable = false, updatable = false, length = 100)
|
|
private String createdBy;
|
|
|
|
@LastModifiedDate
|
|
@Column(nullable = false)
|
|
private LocalDateTime updatedAt;
|
|
|
|
@LastModifiedBy
|
|
@Column(nullable = false, length = 100)
|
|
private String updatedBy;
|
|
}
|
|
```
|
|
|
|
## Projections
|
|
|
|
```java
|
|
// Interface-based projection
|
|
public interface UserSummary {
|
|
Long getId();
|
|
String getUsername();
|
|
String getEmail();
|
|
|
|
@Value("#{target.firstName + ' ' + target.lastName}")
|
|
String getFullName();
|
|
}
|
|
|
|
// Class-based projection (DTO)
|
|
public record UserSummaryDto(
|
|
Long id,
|
|
String username,
|
|
String email
|
|
) {}
|
|
|
|
// Usage
|
|
public interface UserRepository extends JpaRepository<User, Long> {
|
|
List<UserSummary> findAllBy();
|
|
|
|
<T> List<T> findAllBy(Class<T> type);
|
|
}
|
|
|
|
// Service usage
|
|
List<UserSummary> summaries = userRepository.findAllBy();
|
|
List<UserSummaryDto> dtos = userRepository.findAllBy(UserSummaryDto.class);
|
|
```
|
|
|
|
## Query Optimization
|
|
|
|
```java
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Transactional(readOnly = true)
|
|
public class UserQueryService {
|
|
private final UserRepository userRepository;
|
|
private final EntityManager entityManager;
|
|
|
|
// N+1 problem solved with JOIN FETCH
|
|
@Query("SELECT DISTINCT u FROM User u " +
|
|
"LEFT JOIN FETCH u.addresses " +
|
|
"LEFT JOIN FETCH u.roles " +
|
|
"WHERE u.active = true")
|
|
List<User> findAllActiveWithAssociations();
|
|
|
|
// Batch fetching
|
|
@BatchSize(size = 25)
|
|
@OneToMany(mappedBy = "user")
|
|
private List<Order> orders;
|
|
|
|
// EntityGraph for dynamic fetching
|
|
@EntityGraph(attributePaths = {"addresses", "roles"})
|
|
List<User> findAllByActiveTrue();
|
|
|
|
// Pagination to avoid loading all data
|
|
public Page<User> findAllUsers(Pageable pageable) {
|
|
return userRepository.findAll(pageable);
|
|
}
|
|
|
|
// Native query for complex queries
|
|
@Query(value = """
|
|
SELECT u.* FROM users u
|
|
INNER JOIN orders o ON u.id = o.user_id
|
|
WHERE o.created_at >= :since
|
|
GROUP BY u.id
|
|
HAVING COUNT(o.id) >= :minOrders
|
|
""", nativeQuery = true)
|
|
List<User> findFrequentBuyers(@Param("since") LocalDateTime since,
|
|
@Param("minOrders") int minOrders);
|
|
}
|
|
```
|
|
|
|
## Database Migrations (Flyway)
|
|
|
|
```sql
|
|
-- V1__create_users_table.sql
|
|
CREATE TABLE users (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
email VARCHAR(100) NOT NULL UNIQUE,
|
|
password VARCHAR(100) NOT NULL,
|
|
username VARCHAR(50) NOT NULL UNIQUE,
|
|
active BOOLEAN NOT NULL DEFAULT true,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
version BIGINT NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE INDEX idx_users_email ON users(email);
|
|
CREATE INDEX idx_users_username ON users(username);
|
|
CREATE INDEX idx_users_active ON users(active);
|
|
|
|
-- V2__create_addresses_table.sql
|
|
CREATE TABLE addresses (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
street VARCHAR(200) NOT NULL,
|
|
city VARCHAR(100) NOT NULL,
|
|
country VARCHAR(2) NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
|
```
|
|
|
|
## Quick Reference
|
|
|
|
| Annotation | Purpose |
|
|
|------------|---------|
|
|
| `@Entity` | Marks class as JPA entity |
|
|
| `@Table` | Specifies table details and indexes |
|
|
| `@Id` | Marks primary key field |
|
|
| `@GeneratedValue` | Auto-generated primary key strategy |
|
|
| `@Column` | Column constraints and mapping |
|
|
| `@OneToMany/@ManyToOne` | One-to-many/many-to-one relationships |
|
|
| `@ManyToMany` | Many-to-many relationships |
|
|
| `@JoinColumn/@JoinTable` | Join column/table configuration |
|
|
| `@Transactional` | Declares transaction boundaries |
|
|
| `@Query` | Custom JPQL/native queries |
|
|
| `@Modifying` | Marks query as UPDATE/DELETE |
|
|
| `@EntityGraph` | Defines fetch graph for associations |
|
|
| `@Version` | Optimistic locking version field |
|