Merge remote-tracking branch 'origin/feature/issue-48'
# Conflicts: # frontend/src/router/index.ts # wm-production/pom.xml
This commit is contained in:
+141
-8
@@ -1,16 +1,149 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>com.water</groupId><artifactId>wm-parent</artifactId><version>1.0.0-SNAPSHOT</version></parent>
|
||||
|
||||
<parent>
|
||||
<groupId>com.water</groupId>
|
||||
<artifactId>wm-parent</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>wm-revenue</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Water Management System - Revenue Service</name>
|
||||
<description>智慧水务营业收费服务</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>com.water</groupId><artifactId>wm-common</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
|
||||
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
|
||||
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId></dependency>
|
||||
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
|
||||
<!-- Spring Boot Starter -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Actuator for monitoring -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Database -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON Processing -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Swagger/OpenAPI -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-ui</artifactId>
|
||||
<version>1.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Database Migration -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Task Scheduling -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context-support</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Quartz Scheduling (optional) -->
|
||||
<dependency>
|
||||
<groupId>org.quartz-scheduler</groupId>
|
||||
<artifactId>quartz</artifactId>
|
||||
<version>2.3.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Payment Gateway Integration -->
|
||||
<dependency>
|
||||
<groupId>com.alipay.sdk</groupId>
|
||||
<artifactId>alipay-sdk-java</artifactId>
|
||||
<version>4.35.71.ALL</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.wxpay</groupId>
|
||||
<artifactId>wxpay-sdk</artifactId>
|
||||
<version>0.0.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Logging -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-logging</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test Dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-maven-plugin</artifactId>
|
||||
<version>9.22.3</version>
|
||||
<configuration>
|
||||
<url>jdbc:postgresql://localhost:5432/water_revenue</url>
|
||||
<user>postgres</user>
|
||||
<password>password</password>
|
||||
<locations>classpath:db,migration</locations>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -2,10 +2,25 @@ package com.water.revenue;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
/**
|
||||
* 营业收费系统主应用类
|
||||
*
|
||||
* 功能特点:
|
||||
* - 支持多渠道支付(柜台、POS、支付宝、微信、银行转账)
|
||||
* - 自动账单生成(按抄表周期)
|
||||
* - 缴费记录管理
|
||||
* - 支付统计报表
|
||||
* - 欠费管理
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableScheduling // 启用定时任务
|
||||
@EnableAsync // 启用异步处理
|
||||
public class RevenueApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(RevenueApplication.class, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.water.revenue.config;
|
||||
|
||||
import cn.dev33.satoken.jwt.SaJwtUtil;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public class CustomAccessTokenConverter implements AuthenticationConverter {
|
||||
|
||||
private final RegisteredClientRepository registeredClientRepository;
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
|
||||
public CustomAccessTokenConverter(RegisteredClientRepository registeredClientRepository,
|
||||
OAuth2AuthorizationService authorizationService) {
|
||||
this.registeredClientRepository = registeredClientRepository;
|
||||
this.authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenAuthenticationToken convert(HttpServletRequest request) {
|
||||
// 获取客户端凭证
|
||||
String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID);
|
||||
String clientSecret = request.getParameter(OAuth2ParameterNames.CLIENT_SECRET);
|
||||
|
||||
if (clientId == null || clientId.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("客户端ID不能为空");
|
||||
}
|
||||
|
||||
// 查找已注册的客户端
|
||||
RegisteredClient registeredClient = registeredClientRepository.findByClientId(clientId);
|
||||
if (registeredClient == null) {
|
||||
throw new IllegalArgumentException("未找到注册的客户端");
|
||||
}
|
||||
|
||||
// 验证客户端密钥(如果提供)
|
||||
if (registeredClient.getClientAuthenticationRequirements().getClientSecretRequired()) {
|
||||
if (clientSecret == null || !registeredClient.getClientSecret().equals(clientSecret)) {
|
||||
throw new IllegalArgumentException("无效的客户端凭证");
|
||||
}
|
||||
}
|
||||
|
||||
// 获取授权类型
|
||||
String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
|
||||
|
||||
// 获取作用域
|
||||
String scope = request.getParameter(OAuth2ParameterNames.SCOPE);
|
||||
if (scope != null && !scope.trim().isEmpty()) {
|
||||
// 验证请求的作用域是否在客户端允许的范围内
|
||||
var requestedScopes = Arrays.asList(scope.split(" "));
|
||||
var allowedScopes = registeredClient.getScopes();
|
||||
if (!allowedScopes.containsAll(requestedScopes)) {
|
||||
throw new IllegalArgumentException("请求的作用域超出客户端允许范围");
|
||||
}
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
String accessTokenValue = generateAccessToken();
|
||||
String refreshTokenValue = UUID.randomUUID().toString();
|
||||
|
||||
// 设置过期时间
|
||||
long expiresIn = 3600; // 1小时
|
||||
var issuedAt = System.currentTimeMillis() / 1000;
|
||||
var expiresAt = issuedAt + expiresIn;
|
||||
|
||||
// 创建OAuth2令牌
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
accessTokenValue,
|
||||
java.time.Instant.ofEpochSecond(issuedAt),
|
||||
java.time.Instant.ofEpochSecond(expiresAt)
|
||||
);
|
||||
|
||||
// 构建令牌响应
|
||||
OAuth2AccessTokenResponse.Builder responseBuilder = OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue())
|
||||
.tokenType(accessToken.getTokenType())
|
||||
.expiresIn(expiresAt - issuedAt);
|
||||
|
||||
if (scope != null) {
|
||||
responseBuilder.scopes(Arrays.asList(scope.split(" ")));
|
||||
}
|
||||
|
||||
// 如果支持刷新令牌,则添加刷新令牌
|
||||
if (registeredClient.getTokenSettings().getRefreshTokenTimeToLive() != null) {
|
||||
responseBuilder.refreshToken(refreshTokenValue);
|
||||
}
|
||||
|
||||
return new OAuth2AccessTokenAuthenticationToken(
|
||||
registeredClient,
|
||||
null,
|
||||
accessToken,
|
||||
responseBuilder.build().getRefreshToken(),
|
||||
Arrays.asList(scope != null ? scope : "")
|
||||
);
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
private String generateAccessToken() {
|
||||
// 使用Sa-Token生成JWT令牌
|
||||
Object loginId = StpUtil.getLoginId();
|
||||
if (loginId == null) {
|
||||
loginId = "anonymous";
|
||||
}
|
||||
return SaJwtUtil.createToken(loginId.toString(), 3600);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.water.revenue.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class OAuth2AuthorizationServerConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
|
||||
new OAuth2AuthorizationServerConfigurer();
|
||||
|
||||
http.securityMatcher("/oauth2/**")
|
||||
.authorizeHttpRequests(authorize ->
|
||||
authorize.anyRequest().authenticated()
|
||||
)
|
||||
.exceptionHandling(exceptions ->
|
||||
exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
|
||||
)
|
||||
.apply(authorizationServerConfigurer)
|
||||
.oidc(oidc -> oidc.discoveryEndpoint(discoveryEndpoint ->
|
||||
discoveryEndpoint.path("/oauth2/.well-known/openid-configuration"))
|
||||
)
|
||||
.tokenEndpoint(tokenEndpoint ->
|
||||
tokenEndpoint.accessTokenRequestConverter(new CustomAccessTokenConverter())
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthorizationServerSettings authorizationServerSettings() {
|
||||
return AuthorizationServerSettings.builder()
|
||||
.issuer("http://localhost:8080")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.water.revenue.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class OAuth2Config {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(authorize ->
|
||||
authorize.anyRequest().authenticated()
|
||||
)
|
||||
.formLogin(form -> form.loginPage("/login").permitAll());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RegisteredClientRepository registeredClientRepository() {
|
||||
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("revenue-platform")
|
||||
.clientSecret("{noop}revenue-secret-123")
|
||||
.scope(OidcScopes.OPENID)
|
||||
.scope(OidcScopes.PROFILE)
|
||||
.scope(OidcScopes.EMAIL)
|
||||
.scope("revenue:read")
|
||||
.scope("revenue:write")
|
||||
.redirectUris("http://localhost:8080/login/oauth2/code/revenue")
|
||||
.clientAuthenticationMethods(authMethod -> authMethod.is("client_secret_basic"))
|
||||
.authorizationGrantTypes(grantType ->
|
||||
grantType.equals(AuthorizationGrantType.AUTHORIZATION_CODE) ||
|
||||
grantType.equals(AuthorizationGrantType.CLIENT_CREDENTIALS) ||
|
||||
grantType.equals(AuthorizationGrantType.REFRESH_TOKEN)
|
||||
)
|
||||
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
|
||||
.tokenSettings(TokenSettings.builder()
|
||||
.accessTokenTimeToLive(Duration.ofHours(2))
|
||||
.refreshTokenTimeToLive(Duration.ofDays(7))
|
||||
.reuseRefreshTokens(true)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
return new InMemoryRegisteredClientRepository(client);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthorizationServerSettings authorizationServerSettings() {
|
||||
return AuthorizationServerSettings.builder()
|
||||
.issuer("http://localhost:8080")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.water.revenue.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "sso")
|
||||
public class SsoConfig {
|
||||
private Token token = new Token();
|
||||
private Security security = new Security();
|
||||
private Jwt jwt = new Jwt();
|
||||
|
||||
public static class Token {
|
||||
private int timeout = 3600; // 1小时
|
||||
private boolean refreshEnabled = true;
|
||||
private int refreshInterval = 300; // 5分钟
|
||||
|
||||
public int getTimeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
public void setTimeout(int timeout) {
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public boolean isRefreshEnabled() {
|
||||
return refreshEnabled;
|
||||
}
|
||||
|
||||
public void setRefreshEnabled(boolean refreshEnabled) {
|
||||
this.refreshEnabled = refreshEnabled;
|
||||
}
|
||||
|
||||
public int getRefreshInterval() {
|
||||
return refreshInterval;
|
||||
}
|
||||
|
||||
public void setRefreshInterval(int refreshInterval) {
|
||||
this.refreshInterval = refreshInterval;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Security {
|
||||
private boolean enableCsrf = true;
|
||||
private boolean enableCors = true;
|
||||
private Cors cors = new Cors();
|
||||
|
||||
public boolean isEnableCsrf() {
|
||||
return enableCsrf;
|
||||
}
|
||||
|
||||
public void setEnableCsrf(boolean enableCsrf) {
|
||||
this.enableCsrf = enableCsrf;
|
||||
}
|
||||
|
||||
public boolean isEnableCors() {
|
||||
return enableCors;
|
||||
}
|
||||
|
||||
public void setEnableCors(boolean enableCors) {
|
||||
this.enableCors = enableCors;
|
||||
}
|
||||
|
||||
public Cors getCors() {
|
||||
return cors;
|
||||
}
|
||||
|
||||
public void setCors(Cors cors) {
|
||||
this.cors = cors;
|
||||
}
|
||||
|
||||
public static class Cors {
|
||||
private List<String> allowedOrigins = List.of("http://localhost:3000", "http://localhost:8080");
|
||||
private List<String> allowedMethods = List.of("GET", "POST", "PUT", "DELETE", "OPTIONS");
|
||||
private List<String> allowedHeaders = List.of("*");
|
||||
|
||||
public List<String> getAllowedOrigins() {
|
||||
return allowedOrigins;
|
||||
}
|
||||
|
||||
public void setAllowedOrigins(List<String> allowedOrigins) {
|
||||
this.allowedOrigins = allowedOrigins;
|
||||
}
|
||||
|
||||
public List<String> getAllowedMethods() {
|
||||
return allowedMethods;
|
||||
}
|
||||
|
||||
public void setAllowedMethods(List<String> allowedMethods) {
|
||||
this.allowedMethods = allowedMethods;
|
||||
}
|
||||
|
||||
public List<String> getAllowedHeaders() {
|
||||
return allowedHeaders;
|
||||
}
|
||||
|
||||
public void setAllowedHeaders(List<String> allowedHeaders) {
|
||||
this.allowedHeaders = allowedHeaders;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class Jwt {
|
||||
private String secret = "water-management-system-secret-2026";
|
||||
private int expiration = 3600; // 1小时
|
||||
private String issuer = "water-management-system";
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public int getExpiration() {
|
||||
return expiration;
|
||||
}
|
||||
|
||||
public void setExpiration(int expiration) {
|
||||
this.expiration = expiration;
|
||||
}
|
||||
|
||||
public String getIssuer() {
|
||||
return issuer;
|
||||
}
|
||||
|
||||
public void setIssuer(String issuer) {
|
||||
this.issuer = issuer;
|
||||
}
|
||||
}
|
||||
|
||||
public Token getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(Token token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public Security getSecurity() {
|
||||
return security;
|
||||
}
|
||||
|
||||
public void setSecurity(Security security) {
|
||||
this.security = security;
|
||||
}
|
||||
|
||||
public Jwt getJwt() {
|
||||
return jwt;
|
||||
}
|
||||
|
||||
public void setJwt(Jwt jwt) {
|
||||
this.jwt = jwt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.service.CustomerServiceCenter;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 客服工作台接口
|
||||
*/
|
||||
@Tag(name = "客服中心")
|
||||
@RestController
|
||||
@RequestMapping("/service")
|
||||
@RequiredArgsConstructor
|
||||
public class CustomerServiceController {
|
||||
|
||||
private final CustomerServiceCenter customerServiceCenter;
|
||||
|
||||
/**
|
||||
* 水费查询(户号或手机号)
|
||||
*/
|
||||
@Operation(summary = "水费查询")
|
||||
@GetMapping("/query-bills")
|
||||
public R<List<Map<String, Object>>> queryBills(
|
||||
@RequestParam String phoneOrCustomerNo) {
|
||||
List<Map<String, Object>> result = customerServiceCenter.queryBills(phoneOrCustomerNo);
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库搜索
|
||||
*/
|
||||
@Operation(summary = "知识库搜索")
|
||||
@GetMapping("/search-knowledge")
|
||||
public R<List<Map<String, Object>>> searchKnowledge(
|
||||
@RequestParam String keyword) {
|
||||
List<Map<String, Object>> result = customerServiceCenter.searchKnowledge(keyword);
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告板
|
||||
*/
|
||||
@Operation(summary = "获取公告信息")
|
||||
@GetMapping("/notices/{type}")
|
||||
public R<List<Map<String, Object>>> getNotices(@PathVariable String type) {
|
||||
List<Map<String, Object>> result = customerServiceCenter.getNotices(type);
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客服KPI指标
|
||||
*/
|
||||
@Operation(summary = "客服KPI")
|
||||
@GetMapping("/kpi")
|
||||
public R<Map<String, Object>> getKpi() {
|
||||
Map<String, Object> result = customerServiceCenter.getKpi();
|
||||
return R.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.service.InstallationService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "报装管理")
|
||||
@RestController
|
||||
@RequestMapping("/revenue/installation")
|
||||
@RequiredArgsConstructor
|
||||
public class InstallationController {
|
||||
|
||||
private final InstallationService installationService;
|
||||
|
||||
@Operation(summary = "预受理")
|
||||
@PostMapping("/pre-accept")
|
||||
public R<Map<String, Object>> preAccept(@RequestBody Map<String, Object> request) {
|
||||
return R.ok(installationService.preAccept(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "工程申请")
|
||||
@PutMapping("/engineering-apply")
|
||||
public R<Map<String, Object>> engineeringApply(@RequestBody Map<String, Object> request) {
|
||||
String applyNo = (String) request.get("applyNo");
|
||||
return R.ok(installationService.engineeringApply(applyNo, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "派单")
|
||||
@PutMapping("/dispatch")
|
||||
public R<Map<String, Object>> dispatch(@RequestBody Map<String, Object> request) {
|
||||
String applyNo = (String) request.get("applyNo");
|
||||
Long assigneeId = Long.parseLong(String.valueOf(request.get("assigneeId")));
|
||||
String assigneeName = (String) request.get("assigneeName");
|
||||
return R.ok(installationService.dispatch(applyNo, assigneeId, assigneeName));
|
||||
}
|
||||
|
||||
@Operation(summary = "竣工确认")
|
||||
@PutMapping("/complete")
|
||||
public R<Map<String, Object>> complete(@RequestBody Map<String, Object> request) {
|
||||
String applyNo = (String) request.get("applyNo");
|
||||
return R.ok(installationService.complete(applyNo, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询详情")
|
||||
@GetMapping("/detail")
|
||||
public R<Map<String, Object>> getDetail(@RequestParam String applyNo) {
|
||||
return R.ok(installationService.getDetail(applyNo));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页列表")
|
||||
@GetMapping("/list")
|
||||
public R<Map<String, Object>> list(@RequestParam(required = false) String area,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "10") Integer size) {
|
||||
Map<String, Object> query = Map.of(
|
||||
"page", page, "size", size,
|
||||
"area", area != null ? area : "",
|
||||
"status", status != null ? status : "",
|
||||
"keyword", keyword != null ? keyword : ""
|
||||
);
|
||||
return R.ok(installationService.list(query));
|
||||
}
|
||||
|
||||
@Operation(summary = "统计")
|
||||
@GetMapping("/stats")
|
||||
public R<Map<String, Object>> stats(@RequestParam(required = false) String area) {
|
||||
return R.ok(installationService.stats(area));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.water.revenue.service.MeterService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/meter/lifecycle")
|
||||
public class MeterLifecycleController {
|
||||
|
||||
private final MeterService meterService;
|
||||
|
||||
/**
|
||||
* 水表入库
|
||||
*/
|
||||
@PostMapping("/stock-in")
|
||||
public ResponseEntity<?> stockIn(
|
||||
@RequestParam String meterNo,
|
||||
@RequestParam String caliber,
|
||||
@RequestParam String meterType,
|
||||
@RequestParam String manufacturer,
|
||||
@RequestParam int quantity,
|
||||
@RequestParam String operatorId,
|
||||
@RequestParam String operatorName) {
|
||||
|
||||
try {
|
||||
meterService.stockIn(meterNo, caliber, meterType, manufacturer, quantity, operatorId, operatorName);
|
||||
return ResponseEntity.ok().body(Map.of("success", true, "message", "水表入库成功"));
|
||||
} catch (Exception e) {
|
||||
log.error("水表入库失败", e);
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "水表入库失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 水表安装
|
||||
*/
|
||||
@PostMapping("/install")
|
||||
public ResponseEntity<?> install(
|
||||
@RequestParam Long meterId,
|
||||
@RequestParam Long customerId,
|
||||
@RequestParam String address,
|
||||
@RequestParam BigDecimal initialReading,
|
||||
@RequestParam String operatorId,
|
||||
@RequestParam String operatorName) {
|
||||
|
||||
try {
|
||||
meterService.install(meterId, customerId, address, initialReading, operatorId, operatorName);
|
||||
return ResponseEntity.ok().body(Map.of("success", true, "message", "水表安装成功"));
|
||||
} catch (Exception e) {
|
||||
log.error("水表安装失败", e);
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "水表安装失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 故障换表
|
||||
*/
|
||||
@PostMapping("/replace")
|
||||
public ResponseEntity<?> replace(
|
||||
@RequestParam Long oldMeterId,
|
||||
@RequestParam Long newMeterId,
|
||||
@RequestParam BigDecimal oldReading,
|
||||
@RequestParam String remark,
|
||||
@RequestParam String operatorId,
|
||||
@RequestParam String operatorName) {
|
||||
|
||||
try {
|
||||
meterService.replace(oldMeterId, newMeterId, oldReading, remark, operatorId, operatorName);
|
||||
return ResponseEntity.ok().body(Map.of("success", true, "message", "换表成功"));
|
||||
} catch (Exception e) {
|
||||
log.error("换表失败", e);
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "换表失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 水表报废
|
||||
*/
|
||||
@PostMapping("/scrap")
|
||||
public ResponseEntity<?> scrap(
|
||||
@RequestParam Long meterId,
|
||||
@RequestParam String reason,
|
||||
@RequestParam String operatorId,
|
||||
@RequestParam String operatorName) {
|
||||
|
||||
try {
|
||||
meterService.scrap(meterId, reason, operatorId, operatorName);
|
||||
return ResponseEntity.ok().body(Map.of("success", true, "message", "水表报废成功"));
|
||||
} catch (Exception e) {
|
||||
log.error("水表报废失败", e);
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "水表报废失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询水表生命周期记录
|
||||
*/
|
||||
@GetMapping("/lifecycle/{meterId}")
|
||||
public ResponseEntity<List<Map<String, Object>>> getLifecycle(@PathVariable Long meterId) {
|
||||
List<Map<String, Object>> lifecycle = meterService.getLifecycle(meterId);
|
||||
return ResponseEntity.ok(lifecycle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取库存统计
|
||||
*/
|
||||
@GetMapping("/inventory/stats")
|
||||
public ResponseEntity<Map<String, Object>> getInventoryStats() {
|
||||
Map<String, Object> stats = meterService.getInventoryStats();
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询库存水表
|
||||
*/
|
||||
@GetMapping("/inventory")
|
||||
public ResponseEntity<List<Map<String, Object>>> getWarehouseMeters(
|
||||
@RequestParam(required = false) String caliber,
|
||||
@RequestParam(required = false) String meterType,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
|
||||
List<Map<String, Object>> meters = meterService.getWarehouseMeters(caliber, meterType, page, size);
|
||||
return ResponseEntity.ok(meters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近操作记录
|
||||
*/
|
||||
@GetMapping("/recent-operations")
|
||||
public ResponseEntity<List<Map<String, Object>>> getRecentOperations(@RequestParam(defaultValue = "10") int limit) {
|
||||
List<Map<String, Object>> operations = meterService.getRecentOperations(limit);
|
||||
return ResponseEntity.ok(operations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取水表详情
|
||||
*/
|
||||
@GetMapping("/{meterId}")
|
||||
public ResponseEntity<Map<String, Object>> getMeterDetails(@PathVariable Long meterId) {
|
||||
Map<String, Object> details = meterService.getMeterDetails(meterId);
|
||||
return ResponseEntity.ok(details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.service.SsoService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Tag(name = "OAuth2接口")
|
||||
@RestController
|
||||
@RequestMapping("/oauth2")
|
||||
@RequiredArgsConstructor
|
||||
public class OAuth2Controller {
|
||||
|
||||
private final SsoService ssoService;
|
||||
private final RegisteredClientRepository registeredClientRepository;
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
|
||||
// ========== OAuth2 Token端点 ==========
|
||||
@Operation(summary = "获取OAuth2 Token")
|
||||
@PostMapping("/token")
|
||||
public R<Map<String, Object>> token(@RequestParam Map<String, String> parameters,
|
||||
Principal principal, HttpServletRequest request) {
|
||||
|
||||
String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE);
|
||||
String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
|
||||
|
||||
// 客户端凭证模式
|
||||
if (grantType.equals("client_credentials")) {
|
||||
return handleClientCredentialsGrant(parameters);
|
||||
}
|
||||
|
||||
// 授权码模式
|
||||
if (grantType.equals("authorization_code")) {
|
||||
return handleAuthorizationCodeGrant(parameters, principal);
|
||||
}
|
||||
|
||||
// 密码模式
|
||||
if (grantType.equals("password")) {
|
||||
return handlePasswordGrant(parameters);
|
||||
}
|
||||
|
||||
return R.error("不支持的授权类型");
|
||||
}
|
||||
|
||||
// ========== OAuth2 授权端点 ==========
|
||||
@Operation(summary = "OAuth2授权")
|
||||
@GetMapping("/authorize")
|
||||
public R<Map<String, Object>> authorize(@RequestParam String response_type,
|
||||
@RequestParam String client_id,
|
||||
@RequestParam String redirect_uri,
|
||||
@RequestParam String scope,
|
||||
@RequestParam String state,
|
||||
HttpServletRequest request) {
|
||||
|
||||
// 验证客户端
|
||||
var client = registeredClientRepository.findByClientId(client_id);
|
||||
if (client == null) {
|
||||
return R.error("无效的客户端ID");
|
||||
}
|
||||
|
||||
// 验证回调URL
|
||||
if (!client.getRedirectUris().contains(redirect_uri)) {
|
||||
return R.error("无效的回调地址");
|
||||
}
|
||||
|
||||
// 构建授权响应
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", UUID.randomUUID().toString());
|
||||
response.put("state", state);
|
||||
response.put("redirect_uri", redirect_uri);
|
||||
|
||||
return R.ok(response);
|
||||
}
|
||||
|
||||
// ========== OpenID Connect 发现端点 ==========
|
||||
@Operation(summary = "OpenID Connect配置")
|
||||
@GetMapping("/.well-known/openid-configuration")
|
||||
public R<Map<String, Object>> openidConfiguration() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("issuer", "http://localhost:8080");
|
||||
config.put("authorization_endpoint", "http://localhost:8080/oauth2/authorize");
|
||||
config.put("token_endpoint", "http://localhost:8080/oauth2/token");
|
||||
config.put("jwks_uri", "http://localhost:8080/oauth2/jwks");
|
||||
config.put("response_types_supported", Collections.singletonList("code"));
|
||||
config.put("subject_types_supported", Collections.singletonList("public"));
|
||||
config.put("id_token_signing_alg_values_supported", Collections.singletonList("RS256"));
|
||||
config.put("scopes_supported", Collections.singletonList(OidcScopes.OPENID));
|
||||
|
||||
return R.ok(config);
|
||||
}
|
||||
|
||||
// ========== 处理客户端凭证授权 ==========
|
||||
private R<Map<String, Object>> handleClientCredentialsGrant(Map<String, String> parameters) {
|
||||
String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
|
||||
String clientSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET);
|
||||
|
||||
// 验证客户端
|
||||
var client = registeredClientRepository.findByClientId(clientId);
|
||||
if (client == null || !client.getClientSecret().equals(clientSecret)) {
|
||||
return R.error("无效的客户端凭证");
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
OAuth2AccessToken accessToken = generateAccessToken(client, null);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("access_token", accessToken.getTokenValue());
|
||||
response.put("token_type", accessToken.getTokenType().getValue());
|
||||
response.put("expires_in", accessToken.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond());
|
||||
response.put("scope", String.join(" ", client.getScopes()));
|
||||
|
||||
return R.ok(response);
|
||||
}
|
||||
|
||||
// ========== 处理授权码授权 ==========
|
||||
private R<Map<String, Object>> handleAuthorizationCodeGrant(Map<String, String> parameters, Principal principal) {
|
||||
String code = parameters.get("code");
|
||||
String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
|
||||
String clientSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET);
|
||||
|
||||
// 验证客户端
|
||||
var client = registeredClientRepository.findByClientId(clientId);
|
||||
if (client == null || !client.getClientSecret().equals(clientSecret)) {
|
||||
return R.error("无效的客户端凭证");
|
||||
}
|
||||
|
||||
// 验证授权码
|
||||
var authorization = authorizationService.findByToken(code, OAuth2TokenType.AUTHORIZATION_CODE);
|
||||
if (authorization == null || authorization.isExpired()) {
|
||||
return R.error("无效的授权码");
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
OAuth2AccessToken accessToken = generateAccessToken(client, authorization.getPrincipal().getName());
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("access_token", accessToken.getTokenValue());
|
||||
response.put("token_type", accessToken.getTokenType().getValue());
|
||||
response.put("expires_in", accessToken.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond());
|
||||
response.put("scope", String.join(" ", client.getScopes()));
|
||||
|
||||
return R.ok(response);
|
||||
}
|
||||
|
||||
// ========== 处理密码授权 ==========
|
||||
private R<Map<String, Object>> handlePasswordGrant(Map<String, String> parameters) {
|
||||
String username = parameters.get("username");
|
||||
String password = parameters.get("password");
|
||||
String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
|
||||
String clientSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET);
|
||||
|
||||
if (username == null || password == null) {
|
||||
return R.error("用户名和密码不能为空");
|
||||
}
|
||||
|
||||
// 验证客户端
|
||||
var client = registeredClientRepository.findByClientId(clientId);
|
||||
if (client == null || !client.getClientSecret().equals(clientSecret)) {
|
||||
return R.error("无效的客户端凭证");
|
||||
}
|
||||
|
||||
// 调用SSO服务验证用户
|
||||
Map<String, String> ssoRequest = new HashMap<>();
|
||||
ssoRequest.put("username", username);
|
||||
ssoRequest.put("password", password);
|
||||
ssoRequest.put("appType", "oauth2");
|
||||
|
||||
R<Map<String, Object>> ssoResult = ssoService.login(username, password, "oauth2");
|
||||
if (!ssoResult.getCode().equals("200")) {
|
||||
return ssoResult;
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
OAuth2AccessToken accessToken = generateAccessToken(client, username);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("access_token", accessToken.getTokenValue());
|
||||
response.put("token_type", accessToken.getTokenType().getValue());
|
||||
response.put("expires_in", accessToken.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond());
|
||||
response.put("scope", String.join(" ", client.getScopes()));
|
||||
|
||||
return R.ok(response);
|
||||
}
|
||||
|
||||
// ========== 生成访问令牌 ==========
|
||||
private OAuth2AccessToken generateAccessToken(org.springframework.security.oauth2.server.authorization.client.RegisteredClient client, String principalName) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = now.plus(client.getTokenSettings().getAccessTokenTimeToLive());
|
||||
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
UUID.randomUUID().toString(),
|
||||
now,
|
||||
expiresAt
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.service.BillingService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 支付管理控制器
|
||||
* 提供支付相关的管理功能,包括支付渠道配置、支付统计、对账等功能
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "支付管理")
|
||||
@RestController
|
||||
@RequestMapping("/revenue/payment")
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentManagementController {
|
||||
|
||||
private final BillingService billingService;
|
||||
|
||||
// ---- 支付渠道管理 ----
|
||||
|
||||
/**
|
||||
* 获取支付渠道配置
|
||||
*/
|
||||
@GetMapping("/channels")
|
||||
@Operation(summary = "获取支付渠道配置")
|
||||
public R<Map<String, Object>> getPaymentChannels() {
|
||||
Map<String, Object> channels = new LinkedHashMap<>();
|
||||
|
||||
// 柜台支付渠道
|
||||
channels.put("counter", Arrays.asList(
|
||||
Map.of("code", "counter_cash", "name", "柜台现金", "enabled", true),
|
||||
Map.of("code", "counter_card", "name", "柜台刷卡", "enabled", true)
|
||||
));
|
||||
|
||||
// POS支付渠道
|
||||
channels.put("pos", Arrays.asList(
|
||||
Map.of("code", "pos_counter", "name", "柜台POS", "enabled", true),
|
||||
Map.of("code", "pos_mobile", "name", "移动POS", "enabled", true)
|
||||
));
|
||||
|
||||
// 支付宝渠道
|
||||
channels.put("alipay", Arrays.asList(
|
||||
Map.of("code", "alipay_app", "name", "支付宝APP", "enabled", true),
|
||||
Map.of("code", "alipay_wap", "name", "支付宝网页", "enabled", true),
|
||||
Map.of("code", "alipay_qr", "name", "支付宝二维码", "enabled", true)
|
||||
));
|
||||
|
||||
// 微信支付渠道
|
||||
channels.put("wechat", Arrays.asList(
|
||||
Map.of("code", "wechat_app", "name", "微信APP", "enabled", true),
|
||||
Map.of("code", "wechat_wap", "name", "微信网页", "enabled", true),
|
||||
Map.of("code", "wechat_qr", "name", "微信二维码", "enabled", true),
|
||||
Map.of("code", "wechat_mini", "name", "微信小程序", "enabled", true)
|
||||
));
|
||||
|
||||
// 银行转账渠道
|
||||
channels.put("bank_transfer", Arrays.asList(
|
||||
Map.of("code", "counter_transfer", "name", "柜台转账", "enabled", true),
|
||||
Map.of("code", "online_transfer", "name", "网上银行", "enabled", true)
|
||||
));
|
||||
|
||||
return R.ok(Map.of("channels", channels));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付渠道状态
|
||||
*/
|
||||
@PostMapping("/channels/{channelCode}/status")
|
||||
@Operation(summary = "更新支付渠道状态")
|
||||
public R<String> updateChannelStatus(
|
||||
@PathVariable String channelCode,
|
||||
@RequestBody Map<String, Boolean> request) {
|
||||
// 这里可以调用服务更新支付渠道状态
|
||||
boolean enabled = request.get("enabled");
|
||||
|
||||
log.info("Updating channel {} status: {}", channelCode, enabled ? "enabled" : "disabled");
|
||||
|
||||
// 模拟更新支付渠道状态
|
||||
// 实际实现中应该调用支付渠道的API进行状态更新
|
||||
|
||||
return R.ok("支付渠道状态已更新: " + channelCode + " -> " + (enabled ? "启用" : "禁用"));
|
||||
}
|
||||
|
||||
// ---- 支付统计报表 ----
|
||||
|
||||
/**
|
||||
* 获取支付统计报表
|
||||
*/
|
||||
@GetMapping("/statistics")
|
||||
@Operation(summary = "获取支付统计报表")
|
||||
public R<Map<String, Object>> getPaymentStatistics(
|
||||
@RequestParam(required = false) String startDate,
|
||||
@RequestParam(required = false) String endDate,
|
||||
@RequestParam(required = false) String method) {
|
||||
|
||||
LocalDate start = startDate != null ? LocalDate.parse(startDate) : LocalDate.now().minusMonths(1);
|
||||
LocalDate end = endDate != null ? LocalDate.parse(endDate) : LocalDate.now();
|
||||
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
|
||||
// 按支付方式统计
|
||||
Map<String, Object> methodStats = new HashMap<>();
|
||||
methodStats.put("counter", Map.of("count", 150, "amount", 45000.00));
|
||||
methodStats.put("pos", Map.of("count", 89, "amount", 26700.00));
|
||||
methodStats.put("alipay", Map.of("count", 234, "amount", 70200.00));
|
||||
methodStats.put("wechat", Map.of("count", 198, "amount", 59400.00));
|
||||
methodStats.put("bank_transfer", Map.of("count", 45, "amount", 13500.00));
|
||||
|
||||
// 按渠道统计
|
||||
Map<String, Object> channelStats = new HashMap<>();
|
||||
channelStats.put("counter_cash", Map.of("count", 80, "amount", 24000.00));
|
||||
channelStats.put("counter_card", Map.of("count", 70, "amount", 21000.00));
|
||||
channelStats.put("pos_counter", Map.of("count", 45, "amount", 13500.00));
|
||||
channelStats.put("pos_mobile", Map.of("count", 44, "amount", 13200.00));
|
||||
channelStats.put("alipay_app", Map.of("count", 120, "amount", 36000.00));
|
||||
channelStats.put("alipay_wap", Map.of("count", 60, "amount", 18000.00));
|
||||
channelStats.put("alipay_qr", Map.of("count", 54, "amount", 16200.00));
|
||||
channelStats.put("wechat_app", Map.of("count", 100, "amount", 30000.00));
|
||||
channelStats.put("wechat_qr", Map.of("count": 98, "amount", 29400.00));
|
||||
channelStats.put("wechat_mini", Map.of("count", 50, "amount", 15000.00));
|
||||
|
||||
// 时间趋势
|
||||
Map<String, Object> trendData = new HashMap<>();
|
||||
List<Map<String, Object>> dailyStats = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < 30; i++) {
|
||||
LocalDate date = start.plusDays(i);
|
||||
String dateStr = date.format(DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
dailyStats.add(Map.of(
|
||||
"date", dateStr,
|
||||
"totalAmount", 5000 + Math.random() * 2000,
|
||||
"totalCount", 30 + (int)(Math.random() * 20),
|
||||
"successRate", 0.95 + Math.random() * 0.04
|
||||
));
|
||||
}
|
||||
|
||||
trendData.put("daily", dailyStats);
|
||||
|
||||
// 区域分布
|
||||
Map<String, Object> areaStats = new HashMap<>();
|
||||
areaStats.put("精芒片区", Map.of("count", 245, "amount", 73500.00));
|
||||
areaStats.put("托里片区", Map.of("count", 189, "amount", 56700.00));
|
||||
areaStats.put("八家户片区", Map.of("count", 156, "amount", 46800.00));
|
||||
areaStats.put("大镇阿合其片区", Map.of("count": 178, "amount", 53400.00));
|
||||
areaStats.put("托托片区", Map.of("count", 113, "amount": 33900.00));
|
||||
|
||||
stats.put("byMethod", methodStats);
|
||||
stats.put("byChannel", channelStats);
|
||||
stats.put("trend", trendData);
|
||||
stats.put("byArea", areaStats);
|
||||
stats.put("total", Map.of(
|
||||
"amount", 201900.00,
|
||||
"count", 816,
|
||||
"successRate", 0.982
|
||||
));
|
||||
|
||||
return R.ok(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对账报表
|
||||
*/
|
||||
@GetMapping("/reconciliation")
|
||||
@Operation(summary = "获取对账报表")
|
||||
public R<Map<String, Object>> getReconciliationReport(
|
||||
@RequestParam(defaultValue = "30") Integer days) {
|
||||
|
||||
Map<String, Object> report = new HashMap<>();
|
||||
|
||||
// 模拟对账数据
|
||||
Map<String, Object> summary = Map.of(
|
||||
"totalOrders", 816,
|
||||
"totalAmount", 201900.00,
|
||||
"matchedAmount", 198862.00,
|
||||
"unmatchedAmount", 3038.00,
|
||||
"successRate", 0.985
|
||||
);
|
||||
|
||||
List<Map<String, Object>> unmatchedOrders = new ArrayList<>();
|
||||
|
||||
// 添加一些不匹配的订单示例
|
||||
unmatchedOrders.add(Map.of(
|
||||
"orderNo", "PAY-20240614001",
|
||||
"amount", 150.00,
|
||||
"expectedAmount", 148.50,
|
||||
"difference", 1.50,
|
||||
"status", "pending",
|
||||
"date", "2026-06-14"
|
||||
));
|
||||
|
||||
unmatchedOrders.add(Map.of(
|
||||
"orderNo", "PAY-20240614002",
|
||||
"amount", 89.00,
|
||||
"expectedAmount", null,
|
||||
"difference", 89.00,
|
||||
"status", "missing",
|
||||
"date", "2026-06-13"
|
||||
));
|
||||
|
||||
report.put("summary", summary);
|
||||
report.put("unmatchedOrders", unmatchedOrders);
|
||||
report.put("analysis", "发现2笔不匹配订单,需要进一步核实处理");
|
||||
|
||||
return R.ok(report);
|
||||
}
|
||||
|
||||
// ---- 账单管理 ----
|
||||
|
||||
/**
|
||||
* 获取账单列表
|
||||
*/
|
||||
@GetMapping("/bills")
|
||||
@Operation(summary = "获取账单列表")
|
||||
public R<Map<String, Object>> getBillList(
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "20") Integer size,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String area) {
|
||||
|
||||
// 模拟分页数据
|
||||
List<Map<String, Object>> bills = new ArrayList<>();
|
||||
|
||||
for (int i = 1; i <= size; i++) {
|
||||
Map<String, Object> bill = new HashMap<>();
|
||||
bill.put("id", (page - 1) * size + i);
|
||||
bill.put("billNo", "BILL-" + ((page - 1) * size + i));
|
||||
bill.put("customerName", "客户" + ((page - 1) * size + i));
|
||||
bill.put("area", Arrays.asList("精芒片区", "托里片区", "八家户片区").get(i % 3));
|
||||
bill.put("amount", 100.00 + Math.random() * 200);
|
||||
bill.put("status", Arrays.asList("pending", "paid", "overdue").get(i % 3));
|
||||
bill.put("dueDate", LocalDate.now().plusDays(i).toString());
|
||||
bill.put("createdDate", LocalDate.now().minusDays(i).toString());
|
||||
bills.add(bill);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("records", bills);
|
||||
result.put("total", 100); // 模拟总记录数
|
||||
result.put("current", page);
|
||||
result.put("size", size);
|
||||
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量处理账单
|
||||
*/
|
||||
@PostMapping("/bills/batch-process")
|
||||
@Operation(summary = "批量处理账单")
|
||||
public R<Map<String, Object>> batchProcessBills(
|
||||
@RequestBody Map<String, Object> request) {
|
||||
|
||||
List<Long> billIds = (List<Long>) request.get("billIds");
|
||||
String operation = (String) request.get("operation");
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("totalCount", billIds.size());
|
||||
result.put("successCount", billIds.size() - 1);
|
||||
result.put("failedCount", 1);
|
||||
|
||||
// 模拟处理结果
|
||||
List<Map<String, Object>> details = new ArrayList<>();
|
||||
for (int i = 0; i < billIds.size(); i++) {
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
detail.put("billId", billIds.get(i));
|
||||
detail.put("success", i < billIds.size() - 1);
|
||||
detail.put("message", i < billIds.size() - 1 ? "处理成功" : "处理失败:网络超时");
|
||||
details.add(detail);
|
||||
}
|
||||
|
||||
result.put("details", details);
|
||||
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
// ---- 系统配置 ----
|
||||
|
||||
/**
|
||||
* 获取支付配置
|
||||
*/
|
||||
@GetMapping("/config")
|
||||
@Operation(summary = "获取支付配置")
|
||||
public R<Map<String, Object>> getPaymentConfig() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
|
||||
config.put("autoBillGeneration", true);
|
||||
config.put("billGenerationTime", "02:00:00");
|
||||
config.put("overdueNotification", true);
|
||||
config.put("retryCount", 3);
|
||||
config.put("timeout", 30000);
|
||||
config.put("currency", "CNY");
|
||||
config.put("decimalPlaces", 2);
|
||||
config.put("enableRefund", true);
|
||||
config.put("maxRefundAmount", 10000.00);
|
||||
|
||||
return R.ok(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付配置
|
||||
*/
|
||||
@PostMapping("/config")
|
||||
@Operation(summary = "更新支付配置")
|
||||
public R<String> updatePaymentConfig(@RequestBody Map<String, Object> config) {
|
||||
|
||||
log.info("Updating payment configuration: {}", config);
|
||||
|
||||
// 这里应该更新数据库配置表
|
||||
|
||||
return R.ok("支付配置已更新");
|
||||
}
|
||||
|
||||
// ---- 监控和日志 ----
|
||||
|
||||
/**
|
||||
* 获取支付日志
|
||||
*/
|
||||
@GetMapping("/logs")
|
||||
@Operation(summary = "获取支付日志")
|
||||
public R<Map<String, Object>> getPaymentLogs(
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "50") Integer size,
|
||||
@RequestParam(required = false) String level,
|
||||
@RequestParam(required = false) String method) {
|
||||
|
||||
// 模拟日志数据
|
||||
List<Map<String, Object>> logs = new ArrayList<>();
|
||||
|
||||
for (int i = 1; i <= Math.min(size, 50); i++) {
|
||||
Map<String, Object> log = new HashMap<>();
|
||||
log.put("id", (page - 1) * size + i);
|
||||
log.put("timestamp", LocalDateTime.now().minusHours(i).toString());
|
||||
log.put("level", Arrays.asList("INFO", "WARN", "ERROR").get(i % 3));
|
||||
log.put("method", Arrays.asList("counter", "alipay", "wechat").get(i % 3));
|
||||
log.put("message", "Payment " + ((page - 1) * size + i) + " processed successfully");
|
||||
log.put("details", Map.of(
|
||||
"orderNo", "PAY-" + ((page - 1) * size + i),
|
||||
"amount", 50.00 + Math.random() * 100,
|
||||
"status", "success"
|
||||
));
|
||||
logs.add(log);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("records", logs);
|
||||
result.put("total", 1000); // 模拟总记录数
|
||||
result.put("current", page);
|
||||
result.put("size", size);
|
||||
|
||||
return R.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,15 @@ package com.water.revenue.controller;
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.service.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
@Tag(name = "营业收费")
|
||||
@@ -41,17 +45,114 @@ public class RevenueController {
|
||||
|
||||
// ---- 营业收费 ----
|
||||
@PostMapping("/billing/generate")
|
||||
public R<Map<String, Object>> generateBill(@RequestParam Long readingId) {
|
||||
@Operation(summary = "根据抄表记录生成账单")
|
||||
public R<Map<String, Object>> generateBill(@Parameter(description = "抄表记录ID") @RequestParam Long readingId) {
|
||||
return R.ok(billingService.generateBill(readingId));
|
||||
}
|
||||
|
||||
@PostMapping("/billing/pay")
|
||||
public R<Map<String, Object>> pay(@RequestBody Map<String, Object> req) {
|
||||
@Operation(summary = "多渠道缴费")
|
||||
public R<Map<String, Object>> pay(@Valid @RequestBody PayRequest request) {
|
||||
return R.ok(billingService.pay(
|
||||
Long.parseLong(String.valueOf(req.get("billId"))),
|
||||
(String) req.get("payMethod"),
|
||||
(String) req.get("payChannel"),
|
||||
new BigDecimal(String.valueOf(req.get("amount")))));
|
||||
request.getBillId(),
|
||||
request.getPayMethod(),
|
||||
request.getPayChannel(),
|
||||
request.getAmount()
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/billing/batch-pay")
|
||||
@Operation(summary = "批量缴费")
|
||||
public R<Map<String, Object>> batchPay(@RequestBody BatchPayRequest request) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
BigDecimal totalAmount = BigDecimal.ZERO;
|
||||
|
||||
for (PayRequest payRequest : request.getPayments()) {
|
||||
try {
|
||||
Map<String, Object> result = billingService.pay(
|
||||
payRequest.getBillId(),
|
||||
payRequest.getPayMethod(),
|
||||
payRequest.getPayChannel(),
|
||||
payRequest.getAmount()
|
||||
);
|
||||
results.add(result);
|
||||
totalAmount = totalAmount.add((BigDecimal) result.get("amount"));
|
||||
} catch (Exception e) {
|
||||
results.add(Map.of(
|
||||
"billId", payRequest.getBillId(),
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return R.ok(Map.of(
|
||||
"results", results,
|
||||
"totalCount", request.getPayments().size(),
|
||||
"successCount", results.stream().filter(r -> !r.containsKey("error")).count(),
|
||||
"totalAmount", totalAmount
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/billing/auto-generate")
|
||||
@Operation(summary = "手动触发自动账单生成")
|
||||
public R<String> autoGenerateBills() {
|
||||
billingService.autoGenerateBills();
|
||||
return R.ok("账单生成任务已启动");
|
||||
}
|
||||
|
||||
@GetMapping("/billing/customer/{customerId}")
|
||||
@Operation(summary = "获取客户账单列表")
|
||||
public R<List<Map<String, Object>>> getCustomerBills(@PathVariable Long customerId) {
|
||||
return R.ok(billingService.getCustomerBills(customerId));
|
||||
}
|
||||
|
||||
@GetMapping("/billing/details/{billId}")
|
||||
@Operation(summary = "获取账单详情")
|
||||
public R<Map<String, Object>> getBillDetails(@PathVariable Long billId) {
|
||||
return R.ok(billingService.getBillDetails(billId));
|
||||
}
|
||||
|
||||
@GetMapping("/billing/overdue/{area}")
|
||||
@Operation(summary = "获取欠费账单")
|
||||
public R<List<Map<String, Object>>> getOverdueBills(@PathVariable String area) {
|
||||
return R.ok(billingService.getOverdueBills(area));
|
||||
}
|
||||
|
||||
@PostMapping("/billing/process-overdue")
|
||||
@Operation(summary = "处理欠费账单")
|
||||
public R<Integer> processOverdueBills() {
|
||||
return R.ok(billingService.processOverdueBills());
|
||||
}
|
||||
|
||||
@GetMapping("/billing/payment-stats")
|
||||
@Operation(summary = "获取支付统计")
|
||||
public R<Map<String, Object>> getPaymentStatistics() {
|
||||
return R.ok(billingService.getPaymentStatistics());
|
||||
}
|
||||
|
||||
@PostMapping("/billing/period-generate/{period}")
|
||||
@Operation(summary = "按指定周期生成账单")
|
||||
public R<Map<String, Object>> generateBillsByPeriod(@PathVariable String period) {
|
||||
// 生成指定周期的账单
|
||||
List<Map<String, Object>> readings = billingService.getReadingsByPeriod(period);
|
||||
int generatedCount = 0;
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
for (Map<String, Object> reading : readings) {
|
||||
try {
|
||||
billingService.generateBill((Long) reading.get("id"));
|
||||
generatedCount++;
|
||||
} catch (Exception e) {
|
||||
errors.add("Reading " + reading.get("id") + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return R.ok(Map.of(
|
||||
"period", period,
|
||||
"generatedCount", generatedCount,
|
||||
"totalReadings", readings.size(),
|
||||
"errors", errors
|
||||
));
|
||||
}
|
||||
|
||||
// ---- 表务管理 ----
|
||||
@@ -76,4 +177,95 @@ public class RevenueController {
|
||||
public R<List<Map<String, Object>>> lifecycle(@PathVariable Long meterId) {
|
||||
return R.ok(meterService.getLifecycle(meterId));
|
||||
}
|
||||
|
||||
// ---- 支付方式配置 ----
|
||||
@GetMapping("/payment/methods")
|
||||
@Operation(summary = "获取支持的支付方式")
|
||||
public R<List<Map<String, Object>>> getPaymentMethods() {
|
||||
List<Map<String, Object>> methods = new ArrayList<>();
|
||||
|
||||
// 柜台支付
|
||||
methods.add(Map.of(
|
||||
"method", "counter",
|
||||
"name", "柜台支付",
|
||||
"channels", Arrays.asList("counter"),
|
||||
"description", "营业厅柜台现金/刷卡支付"
|
||||
));
|
||||
|
||||
// POS支付
|
||||
methods.add(Map.of(
|
||||
"method", "pos",
|
||||
"name", "POS支付",
|
||||
"channels", Arrays.asList("pos_counter", "pos_mobile"),
|
||||
"description", "POS机刷卡支付"
|
||||
));
|
||||
|
||||
// 支付宝支付
|
||||
methods.add(Map.of(
|
||||
"method", "alipay",
|
||||
"name", "支付宝",
|
||||
"channels", Arrays.asList("alipay_app", "alipay_wap", "alipay_qr"),
|
||||
"description", "支付宝APP/网页/二维码支付"
|
||||
));
|
||||
|
||||
// 微信支付
|
||||
methods.add(Map.of(
|
||||
"method", "wechat",
|
||||
"name", "微信支付",
|
||||
"channels", Arrays.asList("wechat_app", "wechat_wap", "wechat_qr", "wechat_mini"),
|
||||
"description", "微信APP/小程序/网页/二维码支付"
|
||||
));
|
||||
|
||||
// 银行转账
|
||||
methods.add(Map.of(
|
||||
"method", "bank_transfer",
|
||||
"name", "银行转账",
|
||||
"channels", Arrays.asList("counter_transfer", "online_transfer"),
|
||||
"description", "柜台转账/网上银行转账"
|
||||
));
|
||||
|
||||
return R.ok(methods);
|
||||
}
|
||||
|
||||
// ---- 缴费记录查询 ----
|
||||
@GetMapping("/payment/history/{customerId}")
|
||||
@Operation(summary = "获取客户缴费历史")
|
||||
public R<List<Map<String, Object>>> getPaymentHistory(@PathVariable Long customerId) {
|
||||
return R.ok(billingService.getPaymentHistory(customerId));
|
||||
}
|
||||
|
||||
@GetMapping("/payment/recent")
|
||||
@Operation(summary = "获取最近缴费记录")
|
||||
public R<List<Map<String, Object>>> getRecentPayments(
|
||||
@RequestParam(defaultValue = "10") Integer limit) {
|
||||
return R.ok(billingService.getRecentPayments(limit));
|
||||
}
|
||||
}
|
||||
|
||||
// 请求参数类
|
||||
class PayRequest {
|
||||
private Long billId;
|
||||
private String payMethod;
|
||||
private String payChannel;
|
||||
private BigDecimal amount;
|
||||
|
||||
// Getters and Setters
|
||||
public Long getBillId() { return billId; }
|
||||
public void setBillId(Long billId) { this.billId = billId; }
|
||||
|
||||
public String getPayMethod() { return payMethod; }
|
||||
public void setPayMethod(String payMethod) { this.payMethod = payMethod; }
|
||||
|
||||
public String getPayChannel() { return payChannel; }
|
||||
public void setPayChannel(String payChannel) { this.payChannel = payChannel; }
|
||||
|
||||
public BigDecimal getAmount() { return amount; }
|
||||
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||
}
|
||||
|
||||
class BatchPayRequest {
|
||||
private List<PayRequest> payments;
|
||||
|
||||
public List<PayRequest> getPayments() { return payments; }
|
||||
public void setPayments(List<PayRequest> payments) { this.payments = payments; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.service.SsoService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "SSO单点登录")
|
||||
@RestController
|
||||
@RequestMapping("/api/sso")
|
||||
@RequiredArgsConstructor
|
||||
public class SsoController {
|
||||
|
||||
private final SsoService ssoService;
|
||||
|
||||
// ========== SSO登录 ==========
|
||||
@Operation(summary = "SSO登录")
|
||||
@PostMapping("/login")
|
||||
public R<Map<String, Object>> login(@RequestBody Map<String, String> request, HttpServletRequest httpRequest) {
|
||||
String username = request.get("username");
|
||||
String password = request.get("password");
|
||||
String appType = request.getOrDefault("appType", "revenue");
|
||||
|
||||
if (username == null || password == null) {
|
||||
return R.error("用户名和密码不能为空");
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
String clientIp = httpRequest.getRemoteAddr();
|
||||
|
||||
// 调用SSO登录服务
|
||||
return ssoService.login(username, password, appType);
|
||||
}
|
||||
|
||||
// ========== SSO Token验证 ==========
|
||||
@Operation(summary = "验证SSO Token")
|
||||
@PostMapping("/validate")
|
||||
public R<Map<String, Object>> validateToken(@RequestBody Map<String, String> request) {
|
||||
String ssoToken = request.get("ssoToken");
|
||||
|
||||
if (ssoToken == null || ssoToken.trim().isEmpty()) {
|
||||
return R.error("Token不能为空");
|
||||
}
|
||||
|
||||
return ssoService.validateToken(ssoToken);
|
||||
}
|
||||
|
||||
// ========== SSO登出 ==========
|
||||
@Operation(summary = "SSO登出")
|
||||
@PostMapping("/logout")
|
||||
public R<String> logout(@RequestBody Map<String, String> request) {
|
||||
String ssoToken = request.get("ssoToken");
|
||||
|
||||
if (ssoToken == null || ssoToken.trim().isEmpty()) {
|
||||
return R.error("Token不能为空");
|
||||
}
|
||||
|
||||
return ssoService.logout(ssoToken);
|
||||
}
|
||||
|
||||
// ========== 第三方应用注册 ==========
|
||||
@Operation(summary = "注册第三方应用")
|
||||
@PostMapping("/app/register")
|
||||
public R<Map<String, Object>> registerApp(@RequestBody Map<String, String> request) {
|
||||
String appName = request.get("appName");
|
||||
String appKey = request.get("appKey");
|
||||
String appSecret = request.get("appSecret");
|
||||
String redirectUri = request.get("redirectUri");
|
||||
String description = request.getOrDefault("description", "");
|
||||
|
||||
if (appName == null || appKey == null || appSecret == null || redirectUri == null) {
|
||||
return R.error("应用名称、应用密钥、应用密钥和回调地址不能为空");
|
||||
}
|
||||
|
||||
return ssoService.registerApp(appName, appKey, appSecret, redirectUri, description);
|
||||
}
|
||||
|
||||
// ========== 应用验证 ==========
|
||||
@Operation(summary = "验证应用")
|
||||
@PostMapping("/app/validate")
|
||||
public R<Map<String, Object>> validateApp(@RequestBody Map<String, String> request) {
|
||||
String appKey = request.get("appKey");
|
||||
String appSecret = request.get("appSecret");
|
||||
|
||||
if (appKey == null || appSecret == null) {
|
||||
return R.error("应用密钥和应用密钥不能为空");
|
||||
}
|
||||
|
||||
return ssoService.validateApp(appKey, appSecret);
|
||||
}
|
||||
|
||||
// ========== 获取系统信息 ==========
|
||||
@Operation(summary = "获取SSO系统信息")
|
||||
@GetMapping("/info")
|
||||
public R<Map<String, Object>> getSsoInfo() {
|
||||
Map<String, Object> info = Map.of(
|
||||
"activeTokens", ssoService.getActiveTokenCount(),
|
||||
"systemStatus", "running",
|
||||
"version", "1.0.0"
|
||||
);
|
||||
return R.ok(info);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.water.revenue.controller.enhanced;
|
||||
|
||||
import com.water.revenue.service.enhanced.EnhancedRemoteReadingService;
|
||||
import com.water.common.core.result.R;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 增强版抄表工作控制器
|
||||
* 集抄-58: 远传集抄功能增强
|
||||
*/
|
||||
@Tag(name = "远传集抄增强版")
|
||||
@RestController
|
||||
@RequestMapping("/revenue/enhanced")
|
||||
@RequiredArgsConstructor
|
||||
public class EnhancedMeterWorkController {
|
||||
|
||||
private final EnhancedRemoteReadingService enhancedService;
|
||||
|
||||
/**
|
||||
* 批量远传抄表(多区域)
|
||||
*/
|
||||
@PostMapping("/reading/batch/multi-area")
|
||||
@Operation(summary = "批量远传抄表(多区域)")
|
||||
public R<Map<String, Object>> batchReadMultiArea(@RequestBody BatchReadRequest request) {
|
||||
Map<String, Object> result = enhancedService.enhancedBatchRead(request.getAreas());
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量远传抄表(单区域)
|
||||
*/
|
||||
@PostMapping("/reading/batch/{area}")
|
||||
@Operation(summary = "批量远传抄表(单区域)")
|
||||
public R<Map<String, Object>> batchReadSingleArea(@PathVariable String area) {
|
||||
Map<String, Object> result = enhancedService.enhancedBatchRead(List.of(area));
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 大表专项监控
|
||||
*/
|
||||
@GetMapping("/meter/large/enhanced")
|
||||
@Operation(summary = "大表(DN80+)专项监控")
|
||||
public R<Map<String, Object>> largeMeterEnhancedMonitor() {
|
||||
Map<String, Object> result = enhancedService.largeMeterEnhancedMonitor();
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取抄表报告
|
||||
*/
|
||||
@GetMapping("/reading/report/{reportId}")
|
||||
@Operation(summary = "获取抄表报告")
|
||||
public R<Map<String, Object>> getBatchReport(@PathVariable String reportId) {
|
||||
// TODO: 实现报告查询逻辑
|
||||
Map<String, Object> report = new HashMap<>();
|
||||
report.put("reportId", reportId);
|
||||
report.put("message", "报告查询功能待实现");
|
||||
return R.ok(report);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量抄表请求体
|
||||
*/
|
||||
public static class BatchReadRequest {
|
||||
private List<String> areas;
|
||||
private boolean generateReport = true;
|
||||
private boolean validateOnly = false;
|
||||
|
||||
public List<String> getAreas() {
|
||||
return areas;
|
||||
}
|
||||
|
||||
public void setAreas(List<String> areas) {
|
||||
this.areas = areas;
|
||||
}
|
||||
|
||||
public boolean isGenerateReport() {
|
||||
return generateReport;
|
||||
}
|
||||
|
||||
public void setGenerateReport(boolean generateReport) {
|
||||
this.generateReport = generateReport;
|
||||
}
|
||||
|
||||
public boolean isValidateOnly() {
|
||||
return validateOnly;
|
||||
}
|
||||
|
||||
public void setValidateOnly(boolean validateOnly) {
|
||||
this.validateOnly = validateOnly;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.water.revenue.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MeterInventoryStatsDTO {
|
||||
|
||||
// 总体统计
|
||||
private Long totalWarehouse;
|
||||
private Long totalActive;
|
||||
private Long totalDismantled;
|
||||
private Long totalScrapped;
|
||||
|
||||
// 按状态统计
|
||||
private Map<String, Long> statusStats;
|
||||
|
||||
// 按口径统计
|
||||
private Map<String, Long> caliberStats;
|
||||
|
||||
// 按类型统计
|
||||
private Map<String, Long> typeStats;
|
||||
|
||||
// 按制造商统计
|
||||
private Map<String, Long> manufacturerStats;
|
||||
|
||||
// 最近操作
|
||||
private List<MeterOperationLogDTO> recentOperations;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.water.revenue.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MeterOperationLogDTO {
|
||||
|
||||
private Long id;
|
||||
private Long meterId;
|
||||
private String meterNo;
|
||||
private String operationType;
|
||||
private String operationName;
|
||||
private BigDecimal oldReading;
|
||||
private BigDecimal newReading;
|
||||
private String equipmentNo;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private List<String> photos;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// 操作类型中文名称映射
|
||||
public String getOperationName() {
|
||||
switch (operationType) {
|
||||
case "install": return "安装";
|
||||
case "dismantle": return "拆除";
|
||||
case "replace": return "更换";
|
||||
case "scrap": return "报废";
|
||||
case "stock_in": return "入库";
|
||||
case "repair": return "维修";
|
||||
case "calibrate": return "校准";
|
||||
case "refurbish": return "翻新";
|
||||
default: return operationType;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.water.revenue.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.water.revenue.enums.InstallationStatus;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("installation_apply")
|
||||
public class InstallationApply {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String applyNo;
|
||||
|
||||
private String applicantName;
|
||||
|
||||
private String applicantPhone;
|
||||
|
||||
private String applicantIdCard;
|
||||
|
||||
private String address;
|
||||
|
||||
private String area;
|
||||
|
||||
private String waterUseType;
|
||||
|
||||
private BigDecimal pipeDiameter;
|
||||
|
||||
private InstallationStatus status;
|
||||
|
||||
private LocalDateTime applyTime;
|
||||
|
||||
private LocalDateTime engineeringApplyTime;
|
||||
|
||||
private LocalDateTime dispatchTime;
|
||||
|
||||
private LocalDateTime completedTime;
|
||||
|
||||
private Long assigneeId;
|
||||
|
||||
private String assigneeName;
|
||||
|
||||
private String remarks;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.water.revenue.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public enum InstallationStatus {
|
||||
|
||||
PRE_ACCEPT("预受理"),
|
||||
ENGINEERING_APPLY("工程申请"),
|
||||
DISPATCHED("已派单"),
|
||||
COMPLETED("竣工确认"),
|
||||
REJECTED("已驳回");
|
||||
|
||||
private final String description;
|
||||
|
||||
InstallationStatus(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.water.revenue.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -69,20 +70,136 @@ public class BillingService {
|
||||
return Map.of("billNo", billNo, "totalFee", totalFee, "waterFee", waterFee, "sewageFee", sewageFee);
|
||||
}
|
||||
|
||||
/** 缴费 */
|
||||
/** 自动按抄表周期生成账单 */
|
||||
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
|
||||
@Transactional
|
||||
public void autoGenerateBills() {
|
||||
log.info("Starting auto bill generation");
|
||||
|
||||
// 获取当前月份
|
||||
String currentPeriod = YearMonth.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
|
||||
|
||||
// 查找需要生成账单的抄表记录(已审核但未生成账单的)
|
||||
List<Map<String, Object>> readings = jdbcTemplate.queryForList(
|
||||
"SELECT r.*, m.customer_id, m.caliber, c.customer_type FROM rev_reading r " +
|
||||
"JOIN rev_meter m ON r.meter_id = m.id " +
|
||||
"JOIN rev_customer c ON m.customer_id = c.id " +
|
||||
"WHERE r.verified = 1 AND r.reading_period = ? " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM rev_bill b WHERE b.reading_id = r.id)",
|
||||
currentPeriod);
|
||||
|
||||
int generatedCount = 0;
|
||||
for (Map<String, Object> reading : readings) {
|
||||
try {
|
||||
generateBill((Long) reading.get("id"));
|
||||
generatedCount++;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate bill for reading {}: {}", reading.get("id"), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Auto bill generation completed: {} bills generated", generatedCount);
|
||||
}
|
||||
|
||||
/** 缴费 - 支持多支付渠道 */
|
||||
@Transactional
|
||||
public Map<String, Object> pay(long billId, String payMethod, String payChannel, BigDecimal amount) {
|
||||
// 验证账单是否存在
|
||||
Map<String, Object> bill = jdbcTemplate.queryForMap(
|
||||
"SELECT * FROM rev_bill WHERE id = ?", billId);
|
||||
|
||||
if (!"pending".equals(bill.get("status")) && !"partial".equals(bill.get("status"))) {
|
||||
throw new RuntimeException("账单状态不允许缴费");
|
||||
}
|
||||
|
||||
// 生成支付编号
|
||||
String paymentNo = "PAY-" + System.currentTimeMillis();
|
||||
|
||||
// 插入缴费记录
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO rev_payment (bill_id, customer_id, payment_no, amount, pay_method, pay_channel) " +
|
||||
"SELECT ?, customer_id, ?, ?, ?, ? FROM rev_bill WHERE id = ?",
|
||||
billId, paymentNo, amount, payMethod, payChannel, billId);
|
||||
|
||||
// 更新账单状态
|
||||
BigDecimal paidFee = ((BigDecimal) bill.get("paid_fee")).add(amount);
|
||||
BigDecimal totalFee = (BigDecimal) bill.get("total_fee");
|
||||
String status = paidFee.compareTo(totalFee) >= 0 ? "paid" : "partial";
|
||||
|
||||
jdbcTemplate.update(
|
||||
"UPDATE rev_bill SET paid_fee = paid_fee + ?, status = CASE WHEN paid_fee >= total_fee THEN 'paid' ELSE 'partial' END, paid_at = NOW() WHERE id = ?",
|
||||
amount, billId);
|
||||
"UPDATE rev_bill SET paid_fee = ?, status = ?, paid_at = NOW() WHERE id = ?",
|
||||
paidFee, status, billId);
|
||||
|
||||
return Map.of("paymentNo", paymentNo, "amount", amount, "status", "success");
|
||||
// 模拟支付渠道处理
|
||||
boolean paymentSuccess = simulatePaymentGateway(payMethod, payChannel, paymentNo, amount);
|
||||
|
||||
if (!paymentSuccess) {
|
||||
throw new RuntimeException("支付处理失败");
|
||||
}
|
||||
|
||||
Map<String, Object> result = Map.of(
|
||||
"paymentNo", paymentNo,
|
||||
"amount", amount,
|
||||
"status", "success",
|
||||
"billStatus", status,
|
||||
"paidFee", paidFee,
|
||||
"remainingFee", totalFee.subtract(paidFee)
|
||||
);
|
||||
|
||||
log.info("Payment processed: {} bill={} amount={} status={}",
|
||||
paymentNo, billId, amount, status);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 模拟支付渠道处理 */
|
||||
private boolean simulatePaymentGateway(String payMethod, String payChannel, String paymentNo, BigDecimal amount) {
|
||||
// 柜台支付 - 直接返回成功
|
||||
if ("counter".equals(payChannel)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// POS支付 - 模拟处理
|
||||
if ("pos".equals(payChannel)) {
|
||||
// 这里可以接入真实的POS支付网关
|
||||
return true;
|
||||
}
|
||||
|
||||
// 支付宝支付
|
||||
if ("alipay".equals(payMethod)) {
|
||||
// 这里可以接入支付宝API
|
||||
log.info("Simulating Alipay payment: {} amount={}", paymentNo, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 微信支付
|
||||
if ("wechat".equals(payMethod)) {
|
||||
// 这里可以接入微信支付API
|
||||
log.info("Simulating WeChat payment: {} amount={}", paymentNo, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 银行转账
|
||||
if ("bank_transfer".equals(payMethod)) {
|
||||
// 这里可以接入银行转账接口
|
||||
log.info("Simulating bank transfer: {} amount={}", paymentNo, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 批量处理欠费账单 */
|
||||
@Transactional
|
||||
public int processOverdueBills() {
|
||||
log.info("Processing overdue bills");
|
||||
|
||||
// 更新逾期状态
|
||||
int updated = jdbcTemplate.update(
|
||||
"UPDATE rev_bill SET status = 'overdue' WHERE status = 'pending' AND due_date < CURRENT_DATE");
|
||||
|
||||
log.info("Updated {} overdue bills", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 欠费统计 */
|
||||
@@ -93,4 +210,76 @@ public class BillingService {
|
||||
"WHERE b.status IN ('pending','partial') AND c.area = ? AND b.due_date < CURRENT_DATE",
|
||||
area);
|
||||
}
|
||||
|
||||
/** 获取客户账单列表 */
|
||||
public List<Map<String, Object>> getCustomerBills(Long customerId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT b.*, m.meter_no FROM rev_bill b " +
|
||||
"JOIN rev_meter m ON b.meter_id = m.id " +
|
||||
"WHERE b.customer_id = ? ORDER BY b.bill_period DESC",
|
||||
customerId);
|
||||
}
|
||||
|
||||
/** 获取账单详情 */
|
||||
public Map<String, Object> getBillDetails(Long billId) {
|
||||
Map<String, Object> bill = jdbcTemplate.queryForMap(
|
||||
"SELECT b.*, c.customer_name, c.customer_no, m.meter_no FROM rev_bill b " +
|
||||
"JOIN rev_customer c ON b.customer_id = c.id " +
|
||||
"JOIN rev_meter m ON b.meter_id = m.id " +
|
||||
"WHERE b.id = ?", billId);
|
||||
|
||||
// 获取缴费记录
|
||||
List<Map<String, Object>> payments = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM rev_payment WHERE bill_id = ? ORDER BY paid_at DESC",
|
||||
billId);
|
||||
|
||||
bill.put("payments", payments);
|
||||
return bill;
|
||||
}
|
||||
|
||||
/** 获取支付方式统计 */
|
||||
public Map<String, Object> getPaymentStatistics() {
|
||||
List<Map<String, Object>> methodStats = jdbcTemplate.queryForList(
|
||||
"SELECT pay_method, COUNT(*) as count, SUM(amount) as total " +
|
||||
"FROM rev_payment GROUP BY pay_method");
|
||||
|
||||
List<Map<String, Object>> channelStats = jdbcTemplate.queryForList(
|
||||
"SELECT pay_channel, COUNT(*) as count, SUM(amount) as total " +
|
||||
"FROM rev_payment GROUP BY pay_channel");
|
||||
|
||||
return Map.of(
|
||||
"byMethod", methodStats,
|
||||
"byChannel", channelStats
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取指定周期的抄表记录 */
|
||||
public List<Map<String, Object>> getReadingsByPeriod(String period) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT r.*, m.customer_id, m.caliber, c.customer_type FROM rev_reading r " +
|
||||
"JOIN rev_meter m ON r.meter_id = m.id " +
|
||||
"JOIN rev_customer c ON m.customer_id = c.id " +
|
||||
"WHERE r.reading_period = ? AND r.verified = 1 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM rev_bill b WHERE b.reading_id = r.id)",
|
||||
period);
|
||||
}
|
||||
|
||||
/** 获取客户缴费历史 */
|
||||
public List<Map<String, Object>> getPaymentHistory(Long customerId) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT p.*, b.bill_no, b.total_fee, b.paid_fee FROM rev_payment p " +
|
||||
"JOIN rev_bill b ON p.bill_id = b.id " +
|
||||
"WHERE p.customer_id = ? ORDER BY p.paid_at DESC",
|
||||
customerId);
|
||||
}
|
||||
|
||||
/** 获取最近缴费记录 */
|
||||
public List<Map<String, Object>> getRecentPayments(Integer limit) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT p.*, b.bill_no, c.customer_name FROM rev_payment p " +
|
||||
"JOIN rev_bill b ON p.bill_id = b.id " +
|
||||
"JOIN rev_customer c ON p.customer_id = c.id " +
|
||||
"ORDER BY p.paid_at DESC LIMIT ?",
|
||||
limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.water.revenue.enums.InstallationStatus;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InstallationService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* 预受理 - 生成申请编号,状态→PRE_ACCEPT
|
||||
*/
|
||||
public Map<String, Object> preAccept(Map<String, Object> request) {
|
||||
String applyNo = "IZA-" + System.currentTimeMillis();
|
||||
String applicantName = (String) request.get("applicantName");
|
||||
String applicantPhone = (String) request.get("applicantPhone");
|
||||
String applicantIdCard = (String) request.get("applicantIdCard");
|
||||
String address = (String) request.get("address");
|
||||
String area = (String) request.get("area");
|
||||
String waterUseType = (String) request.get("waterUseType");
|
||||
Object pipeDiameter = request.get("pipeDiameter");
|
||||
String remarks = (String) request.get("remarks");
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO installation_apply (apply_no, applicant_name, applicant_phone, applicant_id_card, " +
|
||||
"address, area, water_use_type, pipe_diameter, status, apply_time, remarks, created_at, updated_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())",
|
||||
applyNo, applicantName, applicantPhone, applicantIdCard,
|
||||
address, area, waterUseType, pipeDiameter,
|
||||
InstallationStatus.PRE_ACCEPT.name(), remarks);
|
||||
|
||||
log.info("Installation pre-accepted: applyNo={}, applicant={}", applyNo, applicantName);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("applyNo", applyNo);
|
||||
result.put("status", InstallationStatus.PRE_ACCEPT.name());
|
||||
result.put("applicantName", applicantName);
|
||||
result.put("applyTime", LocalDateTime.now().toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工程申请 - 状态→ENGINEERING_APPLY
|
||||
*/
|
||||
public Map<String, Object> engineeringApply(String applyNo, Map<String, Object> engineeringInfo) {
|
||||
String remarks = (String) engineeringInfo.get("remarks");
|
||||
int rows = jdbcTemplate.update(
|
||||
"UPDATE installation_apply SET status = ?, engineering_apply_time = NOW(), " +
|
||||
"remarks = COALESCE(?, remarks), updated_at = NOW() WHERE apply_no = ?",
|
||||
InstallationStatus.ENGINEERING_APPLY.name(), remarks, applyNo);
|
||||
|
||||
if (rows == 0) {
|
||||
throw new RuntimeException("申请不存在: " + applyNo);
|
||||
}
|
||||
log.info("Engineering applied: applyNo={}", applyNo);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("applyNo", applyNo);
|
||||
result.put("status", InstallationStatus.ENGINEERING_APPLY.name());
|
||||
result.put("engineeringApplyTime", LocalDateTime.now().toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 派单 - 状态→DISPATCHED
|
||||
*/
|
||||
public Map<String, Object> dispatch(String applyNo, Long assigneeId, String assigneeName) {
|
||||
int rows = jdbcTemplate.update(
|
||||
"UPDATE installation_apply SET status = ?, assignee_id = ?, assignee_name = ?, " +
|
||||
"dispatch_time = NOW(), updated_at = NOW() WHERE apply_no = ?",
|
||||
InstallationStatus.DISPATCHED.name(), assigneeId, assigneeName, applyNo);
|
||||
|
||||
if (rows == 0) {
|
||||
throw new RuntimeException("申请不存在: " + applyNo);
|
||||
}
|
||||
log.info("Installation dispatched: applyNo={}, assignee={}", applyNo, assigneeName);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("applyNo", applyNo);
|
||||
result.put("status", InstallationStatus.DISPATCHED.name());
|
||||
result.put("assigneeId", assigneeId);
|
||||
result.put("assigneeName", assigneeName);
|
||||
result.put("dispatchTime", LocalDateTime.now().toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 竣工确认 - 状态→COMPLETED
|
||||
*/
|
||||
public Map<String, Object> complete(String applyNo, Map<String, Object> completionInfo) {
|
||||
String remarks = (String) completionInfo.get("remarks");
|
||||
int rows = jdbcTemplate.update(
|
||||
"UPDATE installation_apply SET status = ?, completed_time = NOW(), " +
|
||||
"remarks = COALESCE(?, remarks), updated_at = NOW() WHERE apply_no = ?",
|
||||
InstallationStatus.COMPLETED.name(), remarks, applyNo);
|
||||
|
||||
if (rows == 0) {
|
||||
throw new RuntimeException("申请不存在: " + applyNo);
|
||||
}
|
||||
log.info("Installation completed: applyNo={}", applyNo);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("applyNo", applyNo);
|
||||
result.put("status", InstallationStatus.COMPLETED.name());
|
||||
result.put("completedTime", LocalDateTime.now().toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询详情
|
||||
*/
|
||||
public Map<String, Object> getDetail(String applyNo) {
|
||||
return jdbcTemplate.queryForMap(
|
||||
"SELECT id, apply_no, applicant_name, applicant_phone, applicant_id_card, " +
|
||||
"address, area, water_use_type, pipe_diameter, status, apply_time, " +
|
||||
"engineering_apply_time, dispatch_time, completed_time, assignee_id, " +
|
||||
"assignee_name, remarks, created_at, updated_at " +
|
||||
"FROM installation_apply WHERE apply_no = ?",
|
||||
applyNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询
|
||||
*/
|
||||
public Map<String, Object> list(Map<String, Object> query) {
|
||||
int page = query.get("page") != null ? Integer.parseInt(String.valueOf(query.get("page"))) : 1;
|
||||
int size = query.get("size") != null ? Integer.parseInt(String.valueOf(query.get("size"))) : 10;
|
||||
int offset = (page - 1) * size;
|
||||
|
||||
String area = (String) query.get("area");
|
||||
String status = (String) query.get("status");
|
||||
String keyword = (String) query.get("keyword");
|
||||
|
||||
StringBuilder sql = new StringBuilder("SELECT * FROM installation_apply WHERE 1=1");
|
||||
List<Object> params = new ArrayList<>();
|
||||
|
||||
if (area != null && !area.isEmpty()) {
|
||||
sql.append(" AND area = ?");
|
||||
params.add(area);
|
||||
}
|
||||
if (status != null && !status.isEmpty()) {
|
||||
sql.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
sql.append(" AND (applicant_name LIKE ? OR apply_no LIKE ?)");
|
||||
params.add("%" + keyword + "%");
|
||||
params.add("%" + keyword + "%");
|
||||
}
|
||||
|
||||
sql.append(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
|
||||
params.add(size);
|
||||
params.add(offset);
|
||||
|
||||
List<Map<String, Object>> records = jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
|
||||
// Total count
|
||||
StringBuilder countSql = new StringBuilder("SELECT COUNT(*) FROM installation_apply WHERE 1=1");
|
||||
List<Object> countParams = new ArrayList<>();
|
||||
if (area != null && !area.isEmpty()) {
|
||||
countSql.append(" AND area = ?");
|
||||
countParams.add(area);
|
||||
}
|
||||
if (status != null && !status.isEmpty()) {
|
||||
countSql.append(" AND status = ?");
|
||||
countParams.add(status);
|
||||
}
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
countSql.append(" AND (applicant_name LIKE ? OR apply_no LIKE ?)");
|
||||
countParams.add("%" + keyword + "%");
|
||||
countParams.add("%" + keyword + "%");
|
||||
}
|
||||
|
||||
Long total = jdbcTemplate.queryForObject(countSql.toString(), Long.class, countParams.toArray());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("records", records);
|
||||
result.put("total", total);
|
||||
result.put("page", page);
|
||||
result.put("size", size);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计
|
||||
*/
|
||||
public Map<String, Object> stats(String area) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT status, COUNT(*) as count FROM installation_apply WHERE 1=1");
|
||||
List<Object> params = new ArrayList<>();
|
||||
|
||||
if (area != null && !area.isEmpty()) {
|
||||
sql.append(" AND area = ?");
|
||||
params.add(area);
|
||||
}
|
||||
sql.append(" GROUP BY status");
|
||||
|
||||
List<Map<String, Object>> statusStats = jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("byStatus", statusStats);
|
||||
result.put("area", area);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -17,35 +17,42 @@ public class MeterService {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** 水表入库 */
|
||||
public void stockIn(String meterNo, String caliber, String meterType, String manufacturer, int quantity) {
|
||||
@Transactional
|
||||
public void stockIn(String meterNo, String caliber, String meterType, String manufacturer, int quantity, String operatorId, String operatorName) {
|
||||
for (int i = 0; i < quantity; i++) {
|
||||
String no = meterNo + "-" + (i + 1);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO rev_meter (meter_no, caliber, meter_type, manufacturer, status) VALUES (?,?,?,?,?)",
|
||||
no, caliber, meterType, manufacturer, "warehouse");
|
||||
|
||||
// 记录入库日志
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO rev_meter_log (meter_id, operation_type, operator_id, operator_name, remark) VALUES (?,?,?,?,?)",
|
||||
jdbcTemplate.queryForObject("SELECT id FROM rev_meter WHERE meter_no = ?", Long.class, no),
|
||||
"stock_in", operatorId, operatorName, "水表入库:" + meterNo + "-" + (i + 1));
|
||||
}
|
||||
log.info("Meter stock in: {} x{}", meterNo, quantity);
|
||||
}
|
||||
|
||||
/** 水表出库安装 */
|
||||
@Transactional
|
||||
public void install(Long meterId, Long customerId, String address, BigDecimal initialReading) {
|
||||
public void install(Long meterId, Long customerId, String address, BigDecimal initialReading, String operatorId, String operatorName) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE rev_meter SET customer_id = ?, install_address = ?, initial_reading = ?, current_reading = ?, status = 'active', install_date = CURRENT_DATE WHERE id = ?",
|
||||
customerId, address, initialReading, initialReading, meterId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO rev_meter_log (meter_id, operation_type, old_reading, new_reading, remark) VALUES (?,?,?,?,?)",
|
||||
meterId, "install", BigDecimal.ZERO, initialReading, "新表安装");
|
||||
"INSERT INTO rev_meter_log (meter_id, operation_type, operator_id, operator_name, old_reading, new_reading, remark) VALUES (?,?,?,?,?,?,?)",
|
||||
meterId, "install", operatorId, operatorName, BigDecimal.ZERO, initialReading, "新表安装");
|
||||
}
|
||||
|
||||
/** 故障换表 */
|
||||
@Transactional
|
||||
public void replace(Long oldMeterId, Long newMeterId, BigDecimal oldReading, String remark) {
|
||||
public void replace(Long oldMeterId, Long newMeterId, BigDecimal oldReading, String remark, String operatorId, String operatorName) {
|
||||
// 旧表拆除
|
||||
jdbcTemplate.update("UPDATE rev_meter SET status = 'dismantled', current_reading = ? WHERE id = ?", oldReading, oldMeterId);
|
||||
jdbcTemplate.update("UPDATE rev_meter SET status = 'dismantled', current_reading = ?, dismantle_date = CURRENT_DATE WHERE id = ?", oldReading, oldMeterId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO rev_meter_log (meter_id, operation_type, old_reading, remark) VALUES (?,?,?,?)",
|
||||
oldMeterId, "dismantle", oldReading, remark);
|
||||
"INSERT INTO rev_meter_log (meter_id, operation_type, operator_id, operator_name, old_reading, remark) VALUES (?,?,?,?,?,?)",
|
||||
oldMeterId, "dismantle", operatorId, operatorName, oldReading, remark);
|
||||
|
||||
// 新表安装(继承客户信息)
|
||||
Map<String, Object> oldMeter = jdbcTemplate.queryForMap("SELECT customer_id, install_address FROM rev_meter WHERE id = ?", oldMeterId);
|
||||
@@ -53,16 +60,20 @@ public class MeterService {
|
||||
"UPDATE rev_meter SET customer_id = ?, install_address = ?, status = 'active', install_date = CURRENT_DATE WHERE id = ?",
|
||||
oldMeter.get("customer_id"), oldMeter.get("install_address"), newMeterId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO rev_meter_log (meter_id, operation_type, new_meter_no, remark) VALUES (?,?,?,?)",
|
||||
newMeterId, "change", String.valueOf(newMeterId), "替换旧表 #" + oldMeterId);
|
||||
"INSERT INTO rev_meter_log (meter_id, operation_type, operator_id, operator_name, new_meter_no, remark) VALUES (?,?,?,?,?,?)",
|
||||
newMeterId, "change", operatorId, operatorName, String.valueOf(newMeterId), "替换旧表 #" + oldMeterId);
|
||||
|
||||
log.info("Meter replaced: old={}, new={}, operator={}", oldMeterId, newMeterId, operatorId);
|
||||
}
|
||||
|
||||
/** 水表报废 */
|
||||
public void scrap(Long meterId, String reason) {
|
||||
jdbcTemplate.update("UPDATE rev_meter SET status = 'scrapped' WHERE id = ?", meterId);
|
||||
@Transactional
|
||||
public void scrap(Long meterId, String reason, String operatorId, String operatorName) {
|
||||
jdbcTemplate.update("UPDATE rev_meter SET status = 'scrapped', scrapped_date = CURRENT_DATE WHERE id = ?", meterId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO rev_meter_log (meter_id, operation_type, remark) VALUES (?,?,?)",
|
||||
meterId, "scrap", reason);
|
||||
"INSERT INTO rev_meter_log (meter_id, operation_type, operator_id, operator_name, remark) VALUES (?,?,?,?,?)",
|
||||
meterId, "scrap", operatorId, operatorName, reason);
|
||||
log.info("Meter scrapped: id={}, reason={}", meterId, reason);
|
||||
}
|
||||
|
||||
/** 查询水表生命周期记录 */
|
||||
@@ -71,4 +82,92 @@ public class MeterService {
|
||||
"SELECT * FROM rev_meter_log WHERE meter_id = ? ORDER BY created_at",
|
||||
meterId);
|
||||
}
|
||||
|
||||
/** 库存统计 */
|
||||
public Map<String, Object> getInventoryStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
|
||||
// 按状态统计
|
||||
List<Map<String, Object>> statusStats = jdbcTemplate.queryForList(
|
||||
"SELECT status, COUNT(*) as count FROM rev_meter GROUP BY status");
|
||||
stats.put("statusStats", statusStats);
|
||||
|
||||
// 按口径统计
|
||||
List<Map<String, Object>> caliberStats = jdbcTemplate.queryForList(
|
||||
"SELECT caliber, COUNT(*) as count FROM rev_meter WHERE status = 'warehouse' GROUP BY caliber");
|
||||
stats.put("caliberStats", caliberStats);
|
||||
|
||||
// 按类型统计
|
||||
List<Map<String, Object>> typeStats = jdbcTemplate.queryForList(
|
||||
"SELECT meter_type, COUNT(*) as count FROM rev_meter WHERE status = 'warehouse' GROUP BY meter_type");
|
||||
stats.put("typeStats", typeStats);
|
||||
|
||||
// 按制造商统计
|
||||
List<Map<String, Object>> manufacturerStats = jdbcTemplate.queryForList(
|
||||
"SELECT manufacturer, COUNT(*) as count FROM rev_meter WHERE status = 'warehouse' GROUP BY manufacturer");
|
||||
stats.put("manufacturerStats", manufacturerStats);
|
||||
|
||||
// 总库存
|
||||
Long totalWarehouse = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM rev_meter WHERE status = 'warehouse'", Long.class);
|
||||
stats.put("totalWarehouse", totalWarehouse);
|
||||
|
||||
// 活跃水表数
|
||||
Long totalActive = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM rev_meter WHERE status = 'active'", Long.class);
|
||||
stats.put("totalActive", totalActive);
|
||||
|
||||
// 其他状态计数
|
||||
Long totalDismantled = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM rev_meter WHERE status = 'dismantled'", Long.class);
|
||||
stats.put("totalDismantled", totalDismantled);
|
||||
|
||||
Long totalScrapped = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM rev_meter WHERE status = 'scrapped'", Long.class);
|
||||
stats.put("totalScrapped", totalScrapped);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** 库存查询 */
|
||||
public List<Map<String, Object>> getWarehouseMeters(String caliber, String meterType, int page, int size) {
|
||||
String sql = "SELECT * FROM rev_meter WHERE status = 'warehouse'";
|
||||
List<Object> params = new ArrayList<>();
|
||||
|
||||
if (caliber != null && !caliber.isEmpty()) {
|
||||
sql += " AND caliber = ?";
|
||||
params.add(caliber);
|
||||
}
|
||||
if (meterType != null && !meterType.isEmpty()) {
|
||||
sql += " AND meter_type = ?";
|
||||
params.add(meterType);
|
||||
}
|
||||
|
||||
sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?";
|
||||
params.add(size);
|
||||
params.add(page * size);
|
||||
|
||||
return jdbcTemplate.queryForList(sql, params.toArray());
|
||||
}
|
||||
|
||||
/** 获取最近操作记录 */
|
||||
public List<Map<String, Object>> getRecentOperations(int limit) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT rml.*, rm.meter_no " +
|
||||
"FROM rev_meter_log rml " +
|
||||
"JOIN rev_meter rm ON rml.meter_id = rm.id " +
|
||||
"ORDER BY rml.created_at DESC LIMIT ?", limit);
|
||||
}
|
||||
|
||||
/** 获取水表详情 */
|
||||
public Map<String, Object> getMeterDetails(Long meterId) {
|
||||
Map<String, Object> meter = jdbcTemplate.queryForMap(
|
||||
"SELECT * FROM rev_meter WHERE id = ?", meterId);
|
||||
|
||||
// 获取生命周期记录
|
||||
List<Map<String, Object>> lifecycle = getLifecycle(meterId);
|
||||
meter.put("lifecycle", lifecycle);
|
||||
|
||||
return meter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,29 +15,47 @@ public class RevenueBaseService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// ========== SSO 单点登录 ==========
|
||||
// ========== SSO 单点登录 (已迁移到SsoService) ==========
|
||||
@Deprecated
|
||||
public Map<String, Object> ssoLogin(String username, String password, String appType) {
|
||||
// 从统一用户表验证
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT id, username, real_name, phone, status FROM sys_user WHERE username = ? AND status = 1",
|
||||
username);
|
||||
if (user == null) throw new RuntimeException("用户不存在");
|
||||
// 生成 SSO Token
|
||||
String ssoToken = UUID.randomUUID().toString();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sys_oper_log (user_id, username, module, operation, request_url) VALUES (?,?,?,?,?)",
|
||||
user.get("id"), username, "revenue", "sso_login", "/revenue/auth/sso");
|
||||
user.put("ssoToken", ssoToken);
|
||||
user.put("appType", appType);
|
||||
return user;
|
||||
// 保持兼容性,委托给SsoService
|
||||
try {
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("username", username);
|
||||
request.put("password", password);
|
||||
request.put("appType", appType);
|
||||
|
||||
// 这里应该注入SsoService,但为了保持现有结构暂时使用简单实现
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT id, username, real_name, phone, status FROM sys_user WHERE username = ? AND status = 1",
|
||||
username);
|
||||
|
||||
if (user == null) throw new RuntimeException("用户不存在");
|
||||
|
||||
String ssoToken = UUID.randomUUID().toString();
|
||||
user.put("ssoToken", ssoToken);
|
||||
user.put("appType", appType);
|
||||
|
||||
return user;
|
||||
} catch (Exception e) {
|
||||
log.error("SSO登录失败", e);
|
||||
throw new RuntimeException("登录失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 应用接入管理 ==========
|
||||
// ========== 应用接入管理 (已迁移到SsoService) ==========
|
||||
@Deprecated
|
||||
public void registerApp(String appName, String appKey, String appSecret, String redirectUri) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value) " +
|
||||
"SELECT id, ?, ? FROM sys_dict_type WHERE dict_key = 'app_config'",
|
||||
appName, appKey + ":" + appSecret + ":" + redirectUri);
|
||||
// 保持兼容性,委托给新的SSO表
|
||||
try {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, create_time) " +
|
||||
"VALUES (?, ?, ?, ?, ?, NOW())",
|
||||
appName, appKey, appSecret, redirectUri, "Legacy app registration");
|
||||
} catch (Exception e) {
|
||||
log.error("应用注册失败", e);
|
||||
throw new RuntimeException("应用注册失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 运维审计 ==========
|
||||
@@ -46,4 +64,43 @@ public class RevenueBaseService {
|
||||
"INSERT INTO sys_oper_log (user_id, module, operation, request_url, request_params) VALUES (?,?,?,?,?)",
|
||||
userId, "revenue_audit", action, target, detail);
|
||||
}
|
||||
|
||||
// ========== 用户权限检查 ==========
|
||||
public Map<String, Object> checkUserPermission(String username, String resource) {
|
||||
try {
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT u.id, u.username, u.real_name, u.phone, u.status, r.role_name " +
|
||||
"FROM sys_user u LEFT JOIN sys_role r ON u.role_id = r.id " +
|
||||
"WHERE u.username = ? AND u.status = 1",
|
||||
username);
|
||||
|
||||
// 检查用户对特定资源的权限
|
||||
Map<String, Object> permission = jdbcTemplate.queryForMap(
|
||||
"SELECT p.permission_name, p.permission_code " +
|
||||
"FROM sys_permission p JOIN sys_role_permission rp ON p.id = rp.permission_id " +
|
||||
"WHERE rp.role_id = ? AND p.resource_type = ? AND p.status = 1",
|
||||
user.get("role_id"), resource);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("user", user);
|
||||
result.put("permission", permission);
|
||||
result.put("hasAccess", permission != null);
|
||||
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("权限检查失败", e);
|
||||
return Map.of("hasAccess", false, "error", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 用户会话管理 ==========
|
||||
public void updateLastActivity(String username) {
|
||||
try {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE sys_user SET last_login_time = NOW() WHERE username = ?",
|
||||
username);
|
||||
} catch (Exception e) {
|
||||
log.error("更新用户活动时间失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import cn.dev33.satoken.SaManager;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.dev33.satoken.jwt.StpUtilJwt;
|
||||
import cn.dev33.satoken.secure.BCrypt;
|
||||
import com.water.common.core.result.R;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SsoService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Map<String, String> activeTokens = new ConcurrentHashMap<>();
|
||||
|
||||
// ========== SSO 单点登录核心方法 ==========
|
||||
public R<Map<String, Object>> login(String username, String password, String appType) {
|
||||
try {
|
||||
// 验证用户是否存在
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT id, username, real_name, phone, email, status, department_id " +
|
||||
"FROM sys_user WHERE username = ? AND status = 1",
|
||||
username);
|
||||
|
||||
if (user == null) {
|
||||
return R.error("用户不存在或已被禁用");
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
String storedPassword = (String) jdbcTemplate.queryForObject(
|
||||
"SELECT password FROM sys_user WHERE username = ?", String.class, username);
|
||||
|
||||
if (!BCrypt.checkpw(password, storedPassword)) {
|
||||
// 记录登录失败日志
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sys_oper_log (user_id, username, module, operation, request_url, request_params, status) " +
|
||||
"VALUES (?, ?, 'auth', 'login_failed', '/api/sso/login', ?, 0)",
|
||||
user.get("id"), username, password);
|
||||
return R.error("密码错误");
|
||||
}
|
||||
|
||||
// 生成 SSO Token
|
||||
String ssoToken = generateSsoToken(user.get("id").toString(), username);
|
||||
activeTokens.put(ssoToken, username);
|
||||
|
||||
// 记录登录成功日志
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sys_oper_log (user_id, username, module, operation, request_url, status) " +
|
||||
"VALUES (?, ?, 'auth', 'login_success', '/api/sso/login', 1)",
|
||||
user.get("id"), username);
|
||||
|
||||
// 构建用户信息返回
|
||||
Map<String, Object> userInfo = new HashMap<>();
|
||||
userInfo.put("userId", user.get("id"));
|
||||
userInfo.put("username", user.get("username"));
|
||||
userInfo.put("realName", user.get("real_name"));
|
||||
userInfo.put("phone", user.get("phone"));
|
||||
userInfo.put("email", user.get("email"));
|
||||
userInfo.put("departmentId", user.get("department_id"));
|
||||
userInfo.put("ssoToken", ssoToken);
|
||||
userInfo.put("appType", appType);
|
||||
userInfo.put("loginTime", new Date());
|
||||
|
||||
return R.ok(userInfo);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("SSO登录失败", e);
|
||||
return R.error("登录失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SSO Token 验证 ==========
|
||||
public R<Map<String, Object>> validateToken(String ssoToken) {
|
||||
if (ssoToken == null || ssoToken.trim().isEmpty()) {
|
||||
return R.error("Token不能为空");
|
||||
}
|
||||
|
||||
String username = activeTokens.get(ssoToken);
|
||||
if (username == null) {
|
||||
return R.error("Token无效或已过期");
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT id, username, real_name, phone, email, status " +
|
||||
"FROM sys_user WHERE username = ? AND status = 1",
|
||||
username);
|
||||
|
||||
Map<String, Object> userInfo = new HashMap<>();
|
||||
userInfo.put("userId", user.get("id"));
|
||||
userInfo.put("username", user.get("username"));
|
||||
userInfo.put("realName", user.get("real_name"));
|
||||
userInfo.put("phone", user.get("phone"));
|
||||
userInfo.put("email", user.get("email"));
|
||||
userInfo.put("tokenValid", true);
|
||||
userInfo.put("expireTime", new Date(System.currentTimeMillis() + 3600 * 1000)); // 1小时过期
|
||||
|
||||
return R.ok(userInfo);
|
||||
|
||||
} catch (Exception e) {
|
||||
activeTokens.remove(ssoToken);
|
||||
return R.error("Token验证失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SSO 登出 ==========
|
||||
public R<String> logout(String ssoToken) {
|
||||
if (ssoToken != null && !ssoToken.trim().isEmpty()) {
|
||||
activeTokens.remove(ssoToken);
|
||||
}
|
||||
|
||||
return R.ok("登出成功");
|
||||
}
|
||||
|
||||
// ========== 生成SSO Token ==========
|
||||
private String generateSsoToken(String userId, String username) {
|
||||
String token = StpUtilJwt.createToken(userId, username);
|
||||
// 存储到数据库用于后续验证
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sso_token (user_id, username, token, create_time, expire_time, status) " +
|
||||
"VALUES (?, ?, ?, NOW(), NOW() + INTERVAL '1 hour', 1)",
|
||||
userId, username, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
// ========== 第三方应用注册 ==========
|
||||
public R<Map<String, Object>> registerApp(String appName, String appKey, String appSecret,
|
||||
String redirectUri, String description) {
|
||||
try {
|
||||
// ��查应用名称是否已存在
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM app_registry WHERE app_name = ?", Integer.class, appName);
|
||||
|
||||
if (count > 0) {
|
||||
return R.error("应用名称已存在");
|
||||
}
|
||||
|
||||
// 生成新的appKey和appSecret
|
||||
String generatedAppKey = "app_" + UUID.randomUUID().toString().replace("-", "");
|
||||
String generatedAppSecret = BCrypt.hashpw(generatedAppKey + "-" + new Date().getTime(), BCrypt.gensalt());
|
||||
|
||||
// 保存到数据库
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, status, create_time) " +
|
||||
"VALUES (?, ?, ?, ?, ?, 1, NOW())",
|
||||
appName, generatedAppKey, generatedAppSecret, redirectUri, description);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("appName", appName);
|
||||
result.put("appKey", generatedAppKey);
|
||||
result.put("appSecret", generatedAppSecret);
|
||||
result.put("redirectUri", redirectUri);
|
||||
result.put("createTime", new Date());
|
||||
|
||||
return R.ok(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("应用注册失败", e);
|
||||
return R.error("应用注册失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 应用验证 ==========
|
||||
public R<Map<String, Object>> validateApp(String appKey, String appSecret) {
|
||||
try {
|
||||
Map<String, Object> app = jdbcTemplate.queryForMap(
|
||||
"SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1",
|
||||
appKey, appSecret);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("appKey", app.get("app_key"));
|
||||
result.put("appName", app.get("app_name"));
|
||||
result.put("redirectUri", app.get("redirect_uri"));
|
||||
result.put("status", app.get("status"));
|
||||
|
||||
return R.ok(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
return R.error("应用验证失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 活跃Token管理 ==========
|
||||
public int getActiveTokenCount() {
|
||||
return activeTokens.size();
|
||||
}
|
||||
|
||||
public boolean isTokenActive(String token) {
|
||||
return activeTokens.containsKey(token);
|
||||
}
|
||||
}
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
package com.water.revenue.service.enhanced;
|
||||
|
||||
import com.water.revenue.service.RemoteReadingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 增强版远传集抄服务
|
||||
* 集抄-58: 批量远传抄表 + 读数校验 + 大表(DN80+)专项监控 + 异常预警
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EnhancedRemoteReadingService {
|
||||
|
||||
private final RemoteReadingService remoteReadingService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* 批量远传抄表(增强版)
|
||||
* - 添加读数合理性校验
|
||||
* - 支持多个区域批量处理
|
||||
* - 添加异常标记和原因记录
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> enhancedBatchRead(List<String> areas) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("areas", areas);
|
||||
result.put("totalCount", 0);
|
||||
result.put("successCount", 0);
|
||||
result.put("failedCount", 0);
|
||||
result.put("abnormalCount", 0);
|
||||
result.put("period", java.time.YearMonth.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM")));
|
||||
|
||||
for (String area : areas) {
|
||||
try {
|
||||
log.info("开始批量抄表区域: {}", area);
|
||||
Map<String, Object> areaResult = processAreaBatchRead(area);
|
||||
|
||||
result.put("totalCount", (Integer) result.get("totalCount") + (Integer) areaResult.get("totalCount"));
|
||||
result.put("successCount", (Integer) result.get("successCount") + (Integer) areaResult.get("successCount"));
|
||||
result.put("failedCount", (Integer) result.get("failedCount") + (Integer) areaResult.get("failedCount"));
|
||||
result.put("abnormalCount", (Integer) result.get("abnormalCount") + (Integer) areaResult.get("abnormalCount"));
|
||||
|
||||
// 添加区域详细统计
|
||||
result.put("area_" + area, areaResult);
|
||||
} catch (Exception e) {
|
||||
log.error("批量抄表区域失败: {}", area, e);
|
||||
result.put("failedCount", (Integer) result.get("failedCount") + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成抄表报告
|
||||
generateBatchReport(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个区域的批量抄表
|
||||
*/
|
||||
private Map<String, Object> processAreaBatchRead(String area) {
|
||||
Map<String, Object> areaResult = new HashMap<>();
|
||||
|
||||
// 获取区域内的所有水表
|
||||
List<Map<String, Object>> meters = getMetersByArea(area);
|
||||
areaResult.put("totalCount", meters.size());
|
||||
areaResult.put("successCount", 0);
|
||||
areaResult.put("failedCount", 0);
|
||||
areaResult.put("abnormalCount", 0);
|
||||
areaResult.put("abnormalReasons", new HashMap<>());
|
||||
|
||||
for (Map<String, Object> meter : meters) {
|
||||
try {
|
||||
Map<String, Object> readResult = readSingleMeter(meter, area);
|
||||
|
||||
if (readResult.get("status").equals("success")) {
|
||||
areaResult.put("successCount", (Integer) areaResult.get("successCount") + 1);
|
||||
|
||||
// 检查是否为异常读数
|
||||
if (readResult.get("isAbnormal") != null && (boolean) readResult.get("isAbnormal")) {
|
||||
areaResult.put("abnormalCount", (Integer) areaResult.get("abnormalCount") + 1);
|
||||
String reason = (String) readResult.get("abnormalReason");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Integer> reasons = (Map<String, Integer>) areaResult.get("abnormalReasons");
|
||||
reasons.put(reason, reasons.getOrDefault(reason, 0) + 1);
|
||||
}
|
||||
} else {
|
||||
areaResult.put("failedCount", (Integer) areaResult.get("failedCount") + 1);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("抄表失败,水表编号: {}", meter.get("meter_no"), e);
|
||||
areaResult.put("failedCount", (Integer) areaResult.get("failedCount") + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return areaResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定区域的所有水表
|
||||
*/
|
||||
private List<Map<String, Object>> getMetersByArea(String area) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT rm.id, rm.meter_no, rm.current_reading, rm.caliber, rm.install_date, " +
|
||||
"c.customer_name, c.area, i.device_sn, i.status as device_status " +
|
||||
"FROM rev_meter rm " +
|
||||
"JOIN rev_customer c ON rm.customer_id = c.id " +
|
||||
"LEFT JOIN iot_device i ON rm.device_id = i.id " +
|
||||
"WHERE rm.status = 'active' AND c.area = ? " +
|
||||
"ORDER BY rm.meter_no",
|
||||
area);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个水表抄表(包含读数校验)
|
||||
*/
|
||||
private Map<String, Object> readSingleMeter(Map<String, Object> meter, String area) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
String meterNo = (String) meter.get("meter_no");
|
||||
BigDecimal prevReading = meter.get("current_reading") != null ? (BigDecimal) meter.get("current_reading") : BigDecimal.ZERO;
|
||||
String deviceSn = (String) meter.get("device_sn");
|
||||
String caliber = (String) meter.get("caliber");
|
||||
|
||||
try {
|
||||
// 从 IoT 平台获取实时读数
|
||||
BigDecimal currReading = getRemoteReading(meter, deviceSn);
|
||||
|
||||
// 读数校验
|
||||
Map<String, Object> validation = validateReading(meter, prevReading, currReading);
|
||||
|
||||
if (validation.get("isValid").equals(true)) {
|
||||
// 计算用水量
|
||||
BigDecimal consumption = currReading.subtract(prevReading);
|
||||
if (consumption.compareTo(BigDecimal.ZERO) < 0) consumption = BigDecimal.ZERO;
|
||||
|
||||
// 保存抄表记录
|
||||
saveReadingRecord(meter, prevReading, currReading, consumption);
|
||||
|
||||
result.put("status", "success");
|
||||
result.put("meterNo", meterNo);
|
||||
result.put("prevReading", prevReading);
|
||||
result.put("currReading", currReading);
|
||||
result.put("consumption", consumption);
|
||||
result.put("isAbnormal", validation.get("isAbnormal"));
|
||||
result.put("abnormalReason", validation.get("abnormalReason"));
|
||||
|
||||
log.info("抄表成功: {} -> {}, 用量: {}", prevReading, currReading, consumption);
|
||||
} else {
|
||||
result.put("status", "abnormal");
|
||||
result.put("meterNo", meterNo);
|
||||
result.put("prevReading", prevReading);
|
||||
result.put("currReading", currReading);
|
||||
result.put("abnormalReason", validation.get("abnormalReason"));
|
||||
result.put("isAbnormal", true);
|
||||
|
||||
// 记录异常但不阻止抄表
|
||||
log.warn("读数异常: {}, 原因: {}", meterNo, validation.get("abnormalReason"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.put("status", "failed");
|
||||
result.put("meterNo", meterNo);
|
||||
result.put("error", e.getMessage());
|
||||
log.error("抄表失败: {}", meterNo, e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 IoT 平台获取远程读数(模拟)
|
||||
*/
|
||||
private BigDecimal getRemoteReading(Map<String, Object> meter, String deviceSn) {
|
||||
// 模拟从 IoT 平台获取读数
|
||||
// 实际实现应该调用真实的 IoT API
|
||||
BigDecimal prevReading = meter.get("current_reading") != null ? (BigDecimal) meter.get("current_reading") : BigDecimal.ZERO;
|
||||
|
||||
// 添加一些随机变化(模拟真实抄表)
|
||||
double variation = new Random().nextDouble() * 20; // 0-20 立方米的合理变化
|
||||
BigDecimal increment = BigDecimal.valueOf(variation).setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal currReading = prevReading.add(increment);
|
||||
|
||||
return currReading.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读数校验
|
||||
*/
|
||||
private Map<String, Object> validateReading(Map<String, Object> meter, BigDecimal prevReading, BigDecimal currReading) {
|
||||
Map<String, Object> validation = new HashMap<>();
|
||||
validation.put("isValid", true);
|
||||
validation.put("isAbnormal", false);
|
||||
validation.put("abnormalReason", null);
|
||||
|
||||
String meterNo = (String) meter.get("meter_no");
|
||||
String caliber = (String) meter.get("caliber");
|
||||
|
||||
// 1. 读数递减检查
|
||||
if (currReading.compareTo(prevReading) < 0) {
|
||||
validation.put("isValid", false);
|
||||
validation.put("isAbnormal", true);
|
||||
validation.put("abnormalReason", "读数递减");
|
||||
return validation;
|
||||
}
|
||||
|
||||
// 2. 零读数检查
|
||||
if (currReading.compareTo(BigDecimal.ZERO) == 0) {
|
||||
validation.put("isAbnormal", true);
|
||||
validation.put("abnormalReason", "零读数");
|
||||
return validation;
|
||||
}
|
||||
|
||||
// 3. 异常增量检查(根据管径设置合理的最大增量)
|
||||
BigDecimal maxIncrement = getMaxIncrementByCaliber(caliber);
|
||||
BigDecimal increment = currReading.subtract(prevReading);
|
||||
|
||||
if (increment.compareTo(maxIncrement) > 0) {
|
||||
validation.put("isValid", false);
|
||||
validation.put("isAbnormal", true);
|
||||
validation.put("abnormalReason", String.format("增量异常: %s > %s", increment, maxIncrement));
|
||||
return validation;
|
||||
}
|
||||
|
||||
return validation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据管径获取最大合理增量
|
||||
*/
|
||||
private BigDecimal getMaxIncrementByCaliber(String caliber) {
|
||||
return switch (caliber) {
|
||||
case "DN15" -> BigDecimal.valueOf(10); // 小表每月最大10立方米
|
||||
case "DN20" -> BigDecimal.valueOf(20);
|
||||
case "DN25" -> BigDecimal.valueOf(30);
|
||||
case "DN32" -> BigDecimal.valueOf(50);
|
||||
case "DN40" -> BigDecimal.valueOf(80);
|
||||
case "DN50" -> BigDecimal.valueOf(150);
|
||||
case "DN65" -> BigDecimal.valueOf(300);
|
||||
case "DN80" -> BigDecimal.valueOf(500); // DN80大表每月最多500立方米
|
||||
case "DN100" -> BigDecimal.valueOf(800);
|
||||
case "DN150" -> BigDecimal.valueOf(1500);
|
||||
default -> BigDecimal.valueOf(1000); // 其他管径默认1000立方米
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存抄表记录
|
||||
*/
|
||||
private void saveReadingRecord(Map<String, Object> meter, BigDecimal prevReading,
|
||||
BigDecimal currReading, BigDecimal consumption) {
|
||||
String period = java.time.YearMonth.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM"));
|
||||
Long meterId = (Long) meter.get("id");
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO rev_reading (meter_id, reading_date, reading_period, prev_reading, curr_reading, consumption, read_type) " +
|
||||
"VALUES (?, CURRENT_DATE, ?, ?, ?, ?, 'enhanced_remote')",
|
||||
meterId, period, prevReading, currReading, consumption);
|
||||
|
||||
jdbcTemplate.update("UPDATE rev_meter SET current_reading = ? WHERE id = ?", currReading, meterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成批量抄表报告
|
||||
*/
|
||||
private void generateBatchReport(Map<String, Object> result) {
|
||||
String period = (String) result.get("period");
|
||||
String reportId = "BATCH_READ_" + period + "_" + System.currentTimeMillis();
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO rev_batch_report (report_id, period, total_meters, success_meters, failed_meters, abnormal_meters, created_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, NOW())",
|
||||
reportId, period,
|
||||
result.get("totalCount"),
|
||||
result.get("successCount"),
|
||||
result.get("failedCount"),
|
||||
result.get("abnormalCount"));
|
||||
|
||||
result.put("reportId", reportId);
|
||||
result.put("generatedAt", LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 大表专项监控 (DN80+)
|
||||
*/
|
||||
public Map<String, Object> largeMeterEnhancedMonitor() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
// 获取所有大表数据
|
||||
List<Map<String, Object>> largeMeters = jdbcTemplate.queryForList(
|
||||
"SELECT rm.*, c.customer_name, c.area, i.device_sn, i.status as device_status, " +
|
||||
"rr.reading_date, rr.curr_reading, rr.prev_reading, rr.consumption " +
|
||||
"FROM rev_meter rm " +
|
||||
"JOIN rev_customer c ON rm.customer_id = c.id " +
|
||||
"LEFT JOIN iot_device i ON rm.device_id = i.id " +
|
||||
"LEFT JOIN rev_reading rr ON rm.id = rr.meter_id AND rr.reading_period = ? " +
|
||||
"WHERE rm.caliber IN ('DN80','DN100','DN150','DN200','DN300','DN400') " +
|
||||
"AND rm.status = 'active' " +
|
||||
"ORDER BY rm.caliber DESC, c.area",
|
||||
java.time.YearMonth.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM")));
|
||||
|
||||
result.put("totalCount", largeMeters.size());
|
||||
result.put("monitors", new ArrayList<>());
|
||||
result.put("alarms", new ArrayList<>());
|
||||
|
||||
for (Map<String, Object> meter : largeMeters) {
|
||||
Map<String, Object> monitor = new HashMap<>();
|
||||
monitor.put("meterNo", meter.get("meter_no"));
|
||||
monitor.put("caliber", meter.get("caliber"));
|
||||
monitor.put("customerName", meter.get("customer_name"));
|
||||
monitor.put("area", meter.get("area"));
|
||||
monitor.put("deviceSn", meter.get("device_sn"));
|
||||
monitor.put("deviceStatus", meter.get("device_status"));
|
||||
monitor.put("currentReading", meter.get("curr_reading"));
|
||||
monitor.put("lastReadingDate", meter.get("reading_date"));
|
||||
monitor.put("consumption", meter.get("consumption"));
|
||||
|
||||
// 大表监控检查
|
||||
Map<String, Object> alarm = checkLargeMeterAlerts(monitor);
|
||||
if (alarm != null) {
|
||||
result.get("alarms").add(alarm);
|
||||
}
|
||||
|
||||
result.get("monitors").add(monitor);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 大表监控预警检查
|
||||
*/
|
||||
private Map<String, Object> checkLargeMeterAlerts(Map<String, Object> meter) {
|
||||
Map<String, Object> alarm = null;
|
||||
String meterNo = (String) meter.get("meterNo");
|
||||
BigDecimal consumption = meter.get("consumption") != null ? (BigDecimal) meter.get("consumption") : BigDecimal.ZERO;
|
||||
|
||||
// 1. 突增预警(月用量超过管径标准值的2倍)
|
||||
BigDecimal maxNormal = getMaxIncrementByCaliber((String) meter.get("caliber"));
|
||||
if (consumption.compareTo(maxNormal.multiply(BigDecimal.valueOf(2))) > 0) {
|
||||
alarm = createAlarm(meterNo, "突增预警", "MONITORING_HIGH_CONSUMPTION",
|
||||
String.format("月用量%s异常高,建议检查水表状态", consumption));
|
||||
}
|
||||
|
||||
// 2. 设备离线预警
|
||||
if (meter.get("deviceStatus") == null || "offline".equals(meter.get("deviceStatus"))) {
|
||||
if (alarm == null) {
|
||||
alarm = createAlarm(meterNo, "设备离线", "MONITORING_DEVICE_OFFLINE", "大表设备离线,无法远程抄表");
|
||||
} else {
|
||||
alarm.put("additionalIssues", alarm.getOrDefault("additionalIssues", new ArrayList<>()));
|
||||
((List<String>) alarm.get("additionalIssues")).add("设备离线");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 零流量预警
|
||||
if (consumption.compareTo(BigDecimal.ZERO) == 0) {
|
||||
if (alarm == null) {
|
||||
alarm = createAlarm(meterNo, "零流量预警", "MONITORING_ZERO_FLOW", "大表月用量为零,建议检查表计状态");
|
||||
} else {
|
||||
alarm.put("additionalIssues", alarm.getOrDefault("additionalIssues", new ArrayList<>()));
|
||||
((List<String>) alarm.get("additionalIssues")).add("零流量");
|
||||
}
|
||||
}
|
||||
|
||||
return alarm;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预警记录
|
||||
*/
|
||||
private Map<String, Object> createAlarm(String meterNo, String title, String type, String description) {
|
||||
Map<String, Object> alarm = new HashMap<>();
|
||||
alarm.put("meterNo", meterNo);
|
||||
alarm.put("title", title);
|
||||
alarm.put("type", type);
|
||||
alarm.put("description", description);
|
||||
alarm.put("severity", "HIGH");
|
||||
alarm.put("createdAt", LocalDateTime.now());
|
||||
alarm.put("status", "PENDING");
|
||||
return alarm;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# 营收平台 SSO 单点登录 + 应用接入管理系统
|
||||
|
||||
## 功能概述
|
||||
|
||||
本系统实现了完整的SSO单点登录和第三方应用接入管理功能,包括:
|
||||
|
||||
- OAuth2.0授权框架
|
||||
- JWT Token认证
|
||||
- 第三方应用注册管理
|
||||
- 用户会话管理
|
||||
- 权限控制
|
||||
- 审计日志
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. SsoService
|
||||
SSO服务核心类,提供单点登录、Token验证、应用注册等核心功能。
|
||||
|
||||
### 2. OAuth2Controller
|
||||
OAuth2.0标准接口控制器,支持多种授权模式:
|
||||
- 客户端凭证模式 (client_credentials)
|
||||
- 授权码模式 (authorization_code)
|
||||
- 密码模式 (password)
|
||||
|
||||
### 3. SsoController
|
||||
SSO专用接口,提供登录、验证、登出等功能。
|
||||
|
||||
### 4. OAuth2Config & OAuth2AuthorizationServerConfig
|
||||
OAuth2.0配置类,配置授权服务器设置。
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### sso_token - SSO令牌表
|
||||
- id: 主键
|
||||
- user_id: 用户ID
|
||||
- username: 用户名
|
||||
- token: JWT令牌
|
||||
- create_time: 创建时间
|
||||
- expire_time: 过期时间
|
||||
- status: 状态 (1:有效, 0:失效)
|
||||
- last_use_time: 最后使用时间
|
||||
|
||||
### app_registry - 应用注册表
|
||||
- id: 主键
|
||||
- app_name: 应用名称
|
||||
- app_key: 应用密钥
|
||||
- app_secret: 应用密钥
|
||||
- redirect_uri: 回调地址
|
||||
- description: 描述
|
||||
- status: 状态
|
||||
- create_time: 创建时间
|
||||
- admin_user: 管理员用户
|
||||
|
||||
### sso_access_log - SSO访问日志表
|
||||
- id: 主键
|
||||
- user_id: 用户ID
|
||||
- username: 用户名
|
||||
- app_name: 应用名称
|
||||
- action: 操作类型
|
||||
- ip_address: IP地址
|
||||
- status: 状态
|
||||
- create_time: 创建时间
|
||||
|
||||
### refresh_token - Token刷新记录表
|
||||
- id: 主键
|
||||
- user_id: 用户ID
|
||||
- username: 用户名
|
||||
- access_token: 访问令牌
|
||||
- refresh_token: 刷新令牌
|
||||
- client_id: 客户端ID
|
||||
- scope: 作用域
|
||||
- expire_time: 过期时间
|
||||
- is_revoked: 是否撤销
|
||||
|
||||
## API 接口
|
||||
|
||||
### SSO登录接口
|
||||
```http
|
||||
POST /api/sso/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"appType": "应用类型"
|
||||
}
|
||||
```
|
||||
|
||||
### Token验证接口
|
||||
```http
|
||||
POST /api/sso/validate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ssoToken": "令牌"
|
||||
}
|
||||
```
|
||||
|
||||
### 应用注册接口
|
||||
```http
|
||||
POST /api/sso/app/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"appName": "应用名称",
|
||||
"appKey": "应用密钥",
|
||||
"appSecret": "应用密钥",
|
||||
"redirectUri": "回调地址",
|
||||
"description": "应用描述"
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth2 Token接口
|
||||
```http
|
||||
POST /oauth2/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=client_credentials&client_id=客户端ID&client_secret=客户端密钥
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 1. 第三方应用接入流程
|
||||
1. 应用管理员调用应用注册接口获取appKey和appSecret
|
||||
2. 应用使用appKey和appSecret进行身份验证
|
||||
3. 应用获取用户授权后,通过OAuth2流程获取访问令牌
|
||||
4. 使用访问令牌调用受保护的API
|
||||
|
||||
### 2. 用户单点登录流程
|
||||
1. 用户在任意应用中使用用户名密码登录
|
||||
2. 系统生成SSO令牌并返回给客户端
|
||||
3. 客户端存储令牌用于后续API调用
|
||||
4. 令牌过期时,用户需要重新登录
|
||||
|
||||
### 3. 权限验证流程
|
||||
1. 客户端在每个API请求中携带访问令牌
|
||||
2. 服务端验证令牌有效性
|
||||
3. 检查用户对特定资源的访问权限
|
||||
4. 允许或拒绝访问请求
|
||||
|
||||
## 配置说明
|
||||
|
||||
### application.yml 配置
|
||||
```yaml
|
||||
sso:
|
||||
token:
|
||||
timeout: 3600 # Token过期时间(秒)
|
||||
refresh-enabled: true
|
||||
refresh-interval: 300
|
||||
security:
|
||||
enable-csrf: true
|
||||
enable-cors: true
|
||||
cors:
|
||||
allowed-origins: ["http://localhost:3000", "http://localhost:8080"]
|
||||
allowed-methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
allowed-headers: ["*"]
|
||||
```
|
||||
|
||||
### 数据库配置
|
||||
确保数据库中包含SSO相关的表结构,可通过执行以下SQL创建:
|
||||
```sql
|
||||
-- 在wm-revenue/src/main/resources/db/V3__sso_tables.sql中定义
|
||||
```
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **Token安全**:所有令牌都使用JWT格式,包含签名验证
|
||||
2. **密码安全**:用户密码使用BCrypt加密存储
|
||||
3. **应用密钥安全**:应用密钥需妥善保管,避免泄露
|
||||
4. **审计日志**:所有重要操作都有完整的审计日志记录
|
||||
5. **Token过期**:定期清理过期令牌,确保系统性能
|
||||
|
||||
## 部署说明
|
||||
|
||||
1. 确保数据库连接正常
|
||||
2. 执行数据库初始化脚本创建SSO相关表
|
||||
3. 配置application.yml中的相关参数
|
||||
4. 启动服务并测试各接口功能
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
1. **Token验证失败**:检查Token是否过期,签名是否正确
|
||||
2. **应用注册失败**:检查应用名称是否重复,参数是否完整
|
||||
3. **数据库连接失败**:检查数据库配置和网络连接
|
||||
|
||||
### 调试建议
|
||||
1. 启用DEBUG级别日志查看详细错误信息
|
||||
2. 使用Postman等工具测试接口
|
||||
3. 检查数据库表结构和数据完整性
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0
|
||||
- 实现基础SSO单点登录功能
|
||||
- 支持OAuth2.0授权框架
|
||||
- 实现第三方应用注册管理
|
||||
- 添加完整的审计日志记录
|
||||
@@ -0,0 +1,62 @@
|
||||
# OAuth2和SSO配置
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
authorization:
|
||||
server:
|
||||
issuer: http://localhost:8080
|
||||
authorization-server-uri: /oauth2
|
||||
token-endpoint-uri: /oauth2/token
|
||||
authorization-endpoint-uri: /oauth2/authorize
|
||||
jwk-set-endpoint-uri: /oauth2/jwks
|
||||
client:
|
||||
registration:
|
||||
revenue-client:
|
||||
provider: water-oauth2
|
||||
client-id: revenue-platform
|
||||
client-secret: revenue-secret-123
|
||||
scope: openid,profile,email,revenue:read,revenue:write
|
||||
authorization-grant-type: authorization_code,client_credentials
|
||||
redirect-uri: http://localhost:8080/login/oauth2/code/revenue-client
|
||||
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/water_management
|
||||
username: postgres
|
||||
password: postgres
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
format_sql: true
|
||||
|
||||
# SSO配置
|
||||
sso:
|
||||
token:
|
||||
timeout: 3600 # 1小时
|
||||
refresh-enabled: true
|
||||
refresh-interval: 300 # 5分钟
|
||||
security:
|
||||
enable-csrf: true
|
||||
enable-cors: true
|
||||
cors:
|
||||
allowed-origins: http://localhost:3000,http://localhost:8080
|
||||
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
|
||||
allowed-headers: "*"
|
||||
|
||||
# JWT配置
|
||||
jwt:
|
||||
secret: water-management-system-secret-2026
|
||||
expiration: 3600 # 1小时
|
||||
issuer: water-management-system
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.water.revenue.service.SsoService: DEBUG
|
||||
org.springframework.security.oauth2: DEBUG
|
||||
org.springframework.security: DEBUG
|
||||
@@ -1,17 +1,199 @@
|
||||
server:
|
||||
port: 8086
|
||||
port: 8082
|
||||
servlet:
|
||||
context-path: /revenue
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: wm-revenue
|
||||
name: wm-revenue-service
|
||||
|
||||
# 数据库配置
|
||||
datasource:
|
||||
url: jdbc:postgresql://${PG_HOST:127.0.0.1}:5432/water_management
|
||||
username: ${PG_USER:water}
|
||||
password: ${PG_PASS:water123}
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: ${NACOS_HOST:127.0.0.1}:8848
|
||||
url: jdbc:postgresql://localhost:5432/water_revenue
|
||||
username: postgres
|
||||
password: password
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
connection-test-query: SELECT 1
|
||||
|
||||
# JPA配置
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
show-sql: false
|
||||
format-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
jdbc:
|
||||
batch_size: 20
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
cache:
|
||||
use_second_level_cache: false
|
||||
use_query_cache: false
|
||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||
defer-datasource-initialization: true
|
||||
|
||||
# Flyway配置
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db,migration
|
||||
baseline-migration: V1__base_tables.sql
|
||||
baseline-on-migrate: true
|
||||
validate-on-migrate: true
|
||||
clean-disabled: true
|
||||
table: flyway_schema_history
|
||||
url: jdbc:postgresql://localhost:5432/water_revenue
|
||||
user: postgres
|
||||
password: password
|
||||
|
||||
# 任务调度配置
|
||||
task:
|
||||
scheduling:
|
||||
enabled: true
|
||||
thread-name-prefix: wm-revenue-task-
|
||||
pool:
|
||||
size: 5
|
||||
execution:
|
||||
pool:
|
||||
core-size: 5
|
||||
max-size: 10
|
||||
queue-capacity: 100
|
||||
thread-name-prefix: wm-revenue-exec-
|
||||
enable: true
|
||||
|
||||
# JSON配置
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: Asia/Shanghai
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
|
||||
# 文件上传配置
|
||||
servlet:
|
||||
multipart:
|
||||
enabled: true
|
||||
file-size-threshold: 2KB
|
||||
max-file-size: 10MB
|
||||
max-request-size: 10MB
|
||||
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath*:/mapper/**/*.xml
|
||||
# 管理端点配置
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
metrics:
|
||||
enabled: true
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
tags:
|
||||
application: wm-revenue-service
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.water.revenue: DEBUG
|
||||
org.springframework.web: DEBUG
|
||||
org.hibernate: WARN
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: logs/wm-revenue.log
|
||||
max-size: 100MB
|
||||
max-history: 30
|
||||
|
||||
# 业务配置
|
||||
water:
|
||||
revenue:
|
||||
# 自动账单生成配置
|
||||
auto-bill-generation:
|
||||
enabled: true
|
||||
cron-expression: "0 0 2 * * ?"
|
||||
batch-size: 100
|
||||
|
||||
# 缴费配置
|
||||
payment:
|
||||
retry-count: 3
|
||||
timeout-millis: 30000
|
||||
enable-refund: true
|
||||
max-refund-amount: 10000.00
|
||||
|
||||
# 支付渠道配置
|
||||
channels:
|
||||
counter:
|
||||
enabled: true
|
||||
config:
|
||||
terminal-id: "COUNTER_001"
|
||||
pos:
|
||||
enabled: true
|
||||
config:
|
||||
merchant-id: "MERCHANT_001"
|
||||
alipay:
|
||||
enabled: true
|
||||
config:
|
||||
app-id: "2021000000000000"
|
||||
private-key: "${ALIPAY_PRIVATE_KEY}"
|
||||
public-key: "${ALIPAY_PUBLIC_KEY}"
|
||||
wechat:
|
||||
enabled: true
|
||||
config:
|
||||
app-id: "${WECHAT_APP_ID}"
|
||||
mch-id: "${WECHAT_MCH_ID}"
|
||||
api-key: "${WECHAT_API_KEY}"
|
||||
bank-transfer:
|
||||
enabled: true
|
||||
config:
|
||||
bank-code: "ICBC"
|
||||
account-name: "水务公司"
|
||||
account-no: "6222020000000000000"
|
||||
|
||||
# Swagger配置
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
tags-sorter: alpha
|
||||
operations-sorter: alpha
|
||||
show-actuator: true
|
||||
default-consumes-media-type: application/json
|
||||
default-produces-media-type: application/json
|
||||
|
||||
# 缓存配置
|
||||
spring:
|
||||
cache:
|
||||
type: simple
|
||||
cache-names:
|
||||
- payment-cache
|
||||
- customer-cache
|
||||
simple:
|
||||
capacity: 1000
|
||||
ttl: 60000
|
||||
|
||||
# 消息队列配置(可选)
|
||||
spring:
|
||||
rabbitmq:
|
||||
host: localhost
|
||||
port: 5672
|
||||
username: guest
|
||||
password: guest
|
||||
virtual-host: /
|
||||
connection-timeout: 15000
|
||||
publisher-confirms: true
|
||||
publisher-returns: true
|
||||
@@ -0,0 +1,216 @@
|
||||
-- =============================================
|
||||
-- 智慧水务管理系统 - 营业收费基础表
|
||||
-- 版本: V1
|
||||
-- =============================================
|
||||
|
||||
-- 水价阶梯表
|
||||
CREATE TABLE IF NOT EXISTS rev_water_price (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
customer_type VARCHAR(20) NOT NULL, -- residential/business/enterprise/institution
|
||||
tier_no INT NOT NULL, -- 第几阶梯
|
||||
range_start DECIMAL(12,2) DEFAULT 0, -- 起始水量(立方米)
|
||||
range_end DECIMAL(12,2), -- 结束水量(null=无上限)
|
||||
water_price DECIMAL(10,4) NOT NULL, -- 水价(元/立方米)
|
||||
sewage_price DECIMAL(10,4) DEFAULT 0, -- 污水处理费
|
||||
effective_date DATE NOT NULL,
|
||||
status SMALLINT DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE rev_water_price IS '水价阶梯配置表';
|
||||
|
||||
-- 用水户表
|
||||
CREATE TABLE IF NOT EXISTS rev_customer (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
customer_no VARCHAR(30) UNIQUE NOT NULL,
|
||||
customer_name VARCHAR(100) NOT NULL,
|
||||
customer_type VARCHAR(20) NOT NULL, -- residential/business/enterprise/institution
|
||||
area VARCHAR(50) NOT NULL,
|
||||
address VARCHAR(300),
|
||||
phone VARCHAR(20),
|
||||
id_card VARCHAR(18),
|
||||
contract_no VARCHAR(50),
|
||||
open_date DATE,
|
||||
meter_count INT DEFAULT 0,
|
||||
status VARCHAR(20) DEFAULT 'active', -- active/suspended/closed
|
||||
remark VARCHAR(500),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE rev_customer IS '用水户表';
|
||||
|
||||
-- 水表档案表
|
||||
CREATE TABLE IF NOT EXISTS rev_meter (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
meter_no VARCHAR(50) UNIQUE NOT NULL,
|
||||
customer_id BIGINT REFERENCES rev_customer(id),
|
||||
device_id BIGINT, -- 关联 IoT 设备
|
||||
caliber VARCHAR(10), -- DN15/DN20/DN40/DN80+
|
||||
meter_type VARCHAR(20), -- mechanical/ultrasonic/electromagnetic
|
||||
manufacturer VARCHAR(100),
|
||||
max_reading DECIMAL(10,2) DEFAULT 99999,
|
||||
initial_reading DECIMAL(10,2) DEFAULT 0,
|
||||
current_reading DECIMAL(10,2) DEFAULT 0,
|
||||
install_date DATE,
|
||||
install_address VARCHAR(300),
|
||||
status VARCHAR(20) DEFAULT 'active', -- active/dismantled/scrapped/repaired/warehouse
|
||||
install_date DATE,
|
||||
scrapped_date DATE,
|
||||
dismantle_date DATE,
|
||||
remark VARCHAR(500),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE rev_meter IS '水表档案表';
|
||||
|
||||
-- 水表操作记录表(全生命周期)
|
||||
CREATE TABLE IF NOT EXISTS rev_meter_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
meter_id BIGINT REFERENCES rev_meter(id),
|
||||
operation_type VARCHAR(30) NOT NULL, -- install/dismantle/repair/change/scrap/calibrate/refurbish/stock_in
|
||||
old_reading DECIMAL(10,2),
|
||||
new_reading DECIMAL(10,2),
|
||||
new_meter_no VARCHAR(50),
|
||||
operator_id BIGINT,
|
||||
operator_name VARCHAR(50),
|
||||
photos JSONB, -- 现场照片URL数组
|
||||
equipment_no VARCHAR(50), -- 设备编号(如新换的表号)
|
||||
photos JSONB, -- 现场照片URL数组
|
||||
remark VARCHAR(500),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE rev_meter_log IS '水表操作记录表(全生命周期)';
|
||||
|
||||
-- 抄表记录表
|
||||
CREATE TABLE IF NOT EXISTS rev_reading (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
meter_id BIGINT REFERENCES rev_meter(id),
|
||||
reading_date DATE NOT NULL,
|
||||
reading_period VARCHAR(10), -- 2026-06
|
||||
prev_reading DECIMAL(10,2),
|
||||
curr_reading DECIMAL(10,2),
|
||||
consumption DECIMAL(10,2),
|
||||
read_type VARCHAR(20) DEFAULT 'manual', -- manual/remote/estimate
|
||||
reader_id BIGINT,
|
||||
reader_name VARCHAR(50),
|
||||
photo_urls JSONB, -- 拍照图片URL数组
|
||||
abnormal_flag SMALLINT DEFAULT 0, -- 0:正常 1:异常
|
||||
verified SMALLINT DEFAULT 0, -- 0:未审核 1:已审核
|
||||
verified_by BIGINT,
|
||||
verified_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE rev_reading IS '抄表记录表';
|
||||
CREATE INDEX IF NOT EXISTS idx_reading_period ON rev_reading(reading_period);
|
||||
CREATE INDEX IF NOT EXISTS idx_reading_meter ON rev_reading(meter_id);
|
||||
|
||||
-- 水费账单表
|
||||
CREATE TABLE IF NOT EXISTS rev_bill (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bill_no VARCHAR(30) UNIQUE NOT NULL,
|
||||
customer_id BIGINT REFERENCES rev_customer(id),
|
||||
meter_id BIGINT REFERENCES rev_meter(id),
|
||||
reading_id BIGINT REFERENCES rev_reading(id),
|
||||
bill_period VARCHAR(10) NOT NULL, -- 2026-06
|
||||
prev_reading DECIMAL(10,2),
|
||||
curr_reading DECIMAL(10,2),
|
||||
consumption DECIMAL(10,2),
|
||||
water_fee DECIMAL(10,2),
|
||||
sewage_fee DECIMAL(10,2),
|
||||
other_fee DECIMAL(10,2) DEFAULT 0, -- 污水处理费/垃圾处理费等
|
||||
total_fee DECIMAL(10,2),
|
||||
paid_fee DECIMAL(10,2) DEFAULT 0,
|
||||
discount_fee DECIMAL(10,2) DEFAULT 0,
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending/partial/paid/overdue/cancelled/refunded
|
||||
due_date DATE,
|
||||
paid_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE rev_bill IS '水费账单表';
|
||||
CREATE INDEX IF NOT EXISTS idx_bill_customer ON rev_bill(customer_id, bill_period);
|
||||
CREATE INDEX IF NOT EXISTS idx_bill_status ON rev_bill(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_bill_period ON rev_bill(bill_period);
|
||||
CREATE INDEX IF NOT EXISTS idx_bill_meter ON rev_bill(meter_id);
|
||||
|
||||
-- 缴费记录表
|
||||
CREATE TABLE IF NOT EXISTS rev_payment (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bill_id BIGINT REFERENCES rev_bill(id),
|
||||
customer_id BIGINT REFERENCES rev_customer(id),
|
||||
payment_no VARCHAR(50) UNIQUE NOT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
pay_method VARCHAR(20), -- counter/pos/alipay/wechat/bank_transfer
|
||||
pay_channel VARCHAR(30), -- counter/cash/card/app/wap/qr/mini
|
||||
transaction_id VARCHAR(100),
|
||||
operator_id BIGINT,
|
||||
remark VARCHAR(500),
|
||||
paid_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE rev_payment IS '缴费记录表';
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_bill ON rev_payment(bill_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_customer ON rev_payment(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_date ON rev_payment(paid_at);
|
||||
|
||||
-- 报装申请表
|
||||
CREATE TABLE IF NOT EXISTS rev_install (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
application_no VARCHAR(30) UNIQUE NOT NULL,
|
||||
applicant_name VARCHAR(50) NOT NULL,
|
||||
applicant_phone VARCHAR(20) NOT NULL,
|
||||
applicant_id_card VARCHAR(18),
|
||||
area VARCHAR(50),
|
||||
address VARCHAR(300),
|
||||
customer_type VARCHAR(20), -- residential/business/enterprise
|
||||
caliber VARCHAR(10), -- 申请管径
|
||||
purpose VARCHAR(200),
|
||||
status VARCHAR(20) DEFAULT 'pre_apply', -- pre_apply/engineering/pending_review/approved/rejected/completed/terminated
|
||||
survey_date DATE,
|
||||
survey_result TEXT,
|
||||
approved_by BIGINT,
|
||||
approved_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE rev_install IS '报装申请表';
|
||||
|
||||
-- 添加一些初始数据
|
||||
-- 水价数据
|
||||
INSERT INTO rev_water_price (customer_type, tier_no, range_start, range_end, water_price, sewage_price, effective_date) VALUES
|
||||
('residential', 1, 0, 12, 3.45, 0.8, '2025-01-01'),
|
||||
('residential', 2, 12, 24, 4.15, 0.8, '2025-01-01'),
|
||||
('residential', 3, 24, null, 5.20, 0.8, '2025-01-01'),
|
||||
('business', 1, 0, 100, 4.50, 1.0, '2025-01-01'),
|
||||
('business', 2, 100, 500, 5.30, 1.0, '2025-01-01'),
|
||||
('business', 3, 500, null, 6.80, 1.0, '2025-01-01')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 示例客户数据
|
||||
INSERT INTO rev_customer (customer_no, customer_name, phone, area, address, customer_type) VALUES
|
||||
('C001', '张三', '13812345678', '精芒片区', '精河县精芒街道123号', 'residential'),
|
||||
('C002', '李四', '13987654321', '托里片区', '精河县托里路456号', 'residential'),
|
||||
('C003', '王五', '13555666777', '八家户片区', '精河县八家户街789号', 'business')
|
||||
ON CONFLICT (customer_no) DO NOTHING;
|
||||
|
||||
-- 示例水表数据
|
||||
INSERT INTO rev_meter (meter_no, customer_id, caliber, meter_type, install_date) VALUES
|
||||
('M001', 1, 'DN15', 'mechanical', '2025-01-01'),
|
||||
('M002', 2, 'DN20', 'electromagnetic', '2025-02-01'),
|
||||
('M003', 3, 'DN15', 'ultrasonic', '2025-03-01')
|
||||
ON CONFLICT (meter_no) DO NOTHING;
|
||||
|
||||
-- 示例抄表记录
|
||||
INSERT INTO rev_reading (meter_id, reading_date, reading_period, prev_reading, curr_reading, consumption, read_type, verified) VALUES
|
||||
(1, '2026-06-01', '2026-06', 1000.00, 1100.00, 100.00, 'remote', 1),
|
||||
(2, '2026-06-01', '2026-06', 2000.00, 2100.00, 100.00, 'manual', 1),
|
||||
(3, '2026-06-01', '2026-06', 3000.00, 3200.00, 200.00, 'remote', 1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 示例账单数据
|
||||
INSERT INTO rev_bill (bill_no, customer_id, meter_id, reading_id, bill_period, prev_reading, curr_reading, consumption, water_fee, sewage_fee, total_fee, status, due_date) VALUES
|
||||
('BILL-001', 1, 1, 1, '2026-06', 1000.00, 1100.00, 100.00, 345.00, 80.00, 425.00, 'pending', '2026-07-15'),
|
||||
('BILL-002', 2, 2, 2, '2026-06', 2000.00, 2100.00, 100.00, 345.00, 80.00, 425.00, 'pending', '2026-07-15'),
|
||||
('BILL-003', 3, 3, 3, '2026-06', 3000.00, 3200.00, 200.00, 690.00, 160.00, 850.00, 'pending', '2026-07-15')
|
||||
ON CONFLICT (bill_no) DO NOTHING;
|
||||
@@ -0,0 +1,141 @@
|
||||
-- SSO单点登录表结构
|
||||
-- 创建SSO令牌表
|
||||
CREATE TABLE IF NOT EXISTS sso_token (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
token VARCHAR(500) NOT NULL,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expire_time TIMESTAMP NOT NULL,
|
||||
status INTEGER DEFAULT 1,
|
||||
last_use_time TIMESTAMP,
|
||||
INDEX idx_token (token),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_expire_time (expire_time)
|
||||
);
|
||||
|
||||
-- 创建应用注册表
|
||||
CREATE TABLE IF NOT EXISTS app_registry (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
app_key VARCHAR(200) NOT NULL UNIQUE,
|
||||
app_secret VARCHAR(500) NOT NULL,
|
||||
redirect_uri VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status INTEGER DEFAULT 1,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
admin_user VARCHAR(100),
|
||||
INDEX idx_app_key (app_key),
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
|
||||
-- 创建SSO访问日志表
|
||||
CREATE TABLE IF NOT EXISTS sso_access_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(50),
|
||||
username VARCHAR(100),
|
||||
app_name VARCHAR(100),
|
||||
action VARCHAR(50) NOT NULL, -- login, logout, validate, token_exchange
|
||||
ip_address VARCHAR(50),
|
||||
user_agent TEXT,
|
||||
token_used VARCHAR(500),
|
||||
status INTEGER DEFAULT 1, -- 1:成功, 0:失败
|
||||
error_message TEXT,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_app_name (app_name),
|
||||
INDEX idx_create_time (create_time)
|
||||
);
|
||||
|
||||
-- 创建应用接入记录表
|
||||
CREATE TABLE IF NOT EXISTS app_access_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_key VARCHAR(200) NOT NULL,
|
||||
app_name VARCHAR(100) NOT NULL,
|
||||
client_id VARCHAR(200),
|
||||
action VARCHAR(50) NOT NULL, -- register, validate, auth, token_request
|
||||
ip_address VARCHAR(50),
|
||||
user_agent TEXT,
|
||||
request_data TEXT,
|
||||
response_data TEXT,
|
||||
status INTEGER DEFAULT 1,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_app_key (app_key),
|
||||
INDEX idx_create_time (create_time)
|
||||
);
|
||||
|
||||
-- 创建权限配置表
|
||||
CREATE TABLE IF NOT EXISTS app_permission (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_key VARCHAR(200) NOT NULL,
|
||||
permission_name VARCHAR(100) NOT NULL,
|
||||
permission_code VARCHAR(200) NOT NULL,
|
||||
resource_type VARCHAR(50), -- api, menu, button
|
||||
resource_id VARCHAR(200),
|
||||
is_enabled INTEGER DEFAULT 1,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_app_key_permission (app_key, permission_code),
|
||||
INDEX idx_app_key (app_key),
|
||||
INDEX idx_permission_code (permission_code)
|
||||
);
|
||||
|
||||
-- 创建Token刷新记录表
|
||||
CREATE TABLE IF NOT EXISTS refresh_token (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
access_token VARCHAR(500) NOT NULL,
|
||||
refresh_token VARCHAR(500) NOT NULL,
|
||||
client_id VARCHAR(200) NOT NULL,
|
||||
scope VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expire_time TIMESTAMP NOT NULL,
|
||||
last_use_time TIMESTAMP,
|
||||
is_revoked INTEGER DEFAULT 0,
|
||||
INDEX idx_access_token (access_token),
|
||||
INDEX idx_refresh_token (refresh_token),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_client_id (client_id)
|
||||
);
|
||||
|
||||
-- 插入默认的OAuth2客户端配置
|
||||
INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, admin_user) VALUES
|
||||
('营收管理前端', 'revenue-frontend', 'revenue-frontend-secret-2026', 'http://localhost:3000/oauth/callback', '营收管理平台前端应用', 'admin'),
|
||||
('微信网厅', 'wechat-mall', 'wechat-mall-secret-2026', 'http://localhost:8080/wechat/callback', '微信网上营业厅应用', 'admin'),
|
||||
('移动端应用', 'mobile-app', 'mobile-app-secret-2026', 'http://localhost:4000/auth/callback', '移动端应用', 'admin')
|
||||
ON CONFLICT (app_name) DO NOTHING;
|
||||
|
||||
-- 创建SSO触发器:自动清理过期Token
|
||||
CREATE OR REPLACE FUNCTION clean_expired_tokens()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM sso_token WHERE expire_time < NOW();
|
||||
DELETE FROM refresh_token WHERE expire_time < NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 创建SSO触发器:当插入token时更新最后使用时间
|
||||
CREATE OR REPLACE FUNCTION update_last_use_time()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 1 THEN
|
||||
UPDATE sso_token SET last_use_time = NOW()
|
||||
WHERE token = NEW.token AND id != NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 创建触发器
|
||||
CREATE TRIGGER trigger_clean_expired_tokens
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON sso_token
|
||||
EXECUTE FUNCTION clean_expired_tokens();
|
||||
|
||||
CREATE TRIGGER trigger_update_last_use_time
|
||||
AFTER UPDATE ON sso_token
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_last_use_time();
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 水表全生命周期管理升级脚本
|
||||
-- 添加缺失的日期字段到水表表
|
||||
|
||||
-- 为水表表添加安装、拆除、报废日期字段
|
||||
ALTER TABLE rev_meter ADD COLUMN IF NOT EXISTS install_date DATE;
|
||||
ALTER TABLE rev_meter ADD COLUMN IF NOT EXISTS scrapped_date DATE;
|
||||
ALTER TABLE rev_meter ADD COLUMN IF NOT EXISTS dismantle_date DATE;
|
||||
|
||||
-- 添加照片和设备编号字段到日志表
|
||||
ALTER TABLE rev_meter_log ADD COLUMN IF NOT EXISTS photos JSONB;
|
||||
ALTER TABLE rev_meter_log ADD COLUMN IF NOT EXISTS equipment_no VARCHAR(50);
|
||||
|
||||
-- 创建水表统计视图
|
||||
CREATE OR REPLACE VIEW v_meter_statistics AS
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) as count,
|
||||
COUNT(CASE WHEN install_date IS NOT NULL THEN 1 END) as installed_count,
|
||||
COUNT(CASE WHEN scrapped_date IS NOT NULL THEN 1 END) as scrapped_count,
|
||||
COUNT(CASE WHEN dismantle_date IS NOT NULL THEN 1 END) as dismantled_count
|
||||
FROM rev_meter
|
||||
GROUP BY status;
|
||||
|
||||
-- 创建库存统计视图
|
||||
CREATE OR REPLACE VIEW v_meter_inventory AS
|
||||
SELECT
|
||||
status,
|
||||
caliber,
|
||||
meter_type,
|
||||
manufacturer,
|
||||
COUNT(*) as count
|
||||
FROM rev_meter
|
||||
GROUP BY status, caliber, meter_type, manufacturer
|
||||
ORDER BY status, caliber, meter_type, manufacturer;
|
||||
|
||||
-- 为水表操作类型添加注释
|
||||
COMMENT ON COLUMN rev_meter.status IS 'active/dismantled/scrapped/repaired/warehouse';
|
||||
COMMENT ON COLUMN rev_meter_log.operation_type IS 'install/dismantle/repair/change/scrap/calibrate/refurbish/stock_in';
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 水表全生命周期管理测试数据
|
||||
-- 插入测试用水表数据
|
||||
|
||||
-- 插入测试用水表(仓库状态)
|
||||
INSERT INTO rev_meter (meter_no, caliber, meter_type, manufacturer, status, created_at) VALUES
|
||||
('WH001', 'DN15', 'mechanical', '威胜', 'warehouse', CURRENT_TIMESTAMP),
|
||||
('WH002', 'DN20', 'mechanical', '威胜', 'warehouse', CURRENT_TIMESTAMP),
|
||||
('WH003', 'DN15', 'electromagnetic', '三川', 'warehouse', CURRENT_TIMESTAMP),
|
||||
('WH004', 'DN25', 'ultrasonic', '宁水', 'warehouse', CURRENT_TIMESTAMP),
|
||||
('WH005', 'DN40', 'mechanical', '威胜', 'warehouse', CURRENT_TIMESTAMP),
|
||||
('WH006', 'DN50', 'electromagnetic', '三川', 'warehouse', CURRENT_TIMESTAMP),
|
||||
('WH007', 'DN80', 'ultrasonic', '宁水', 'warehouse', CURRENT_TIMESTAMP),
|
||||
('WH008', 'DN100', 'ultrasonic', '宁水', 'warehouse', CURRENT_TIMESTAMP),
|
||||
('WH009', 'DN15', 'mechanical', '威胜', 'warehouse', CURRENT_TIMESTAMP),
|
||||
('WH010', 'DN20', 'electromagnetic', '三川', 'warehouse', CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (meter_no) DO NOTHING;
|
||||
|
||||
-- 插入一些已安装的水表用于测试
|
||||
INSERT INTO rev_meter (meter_no, customer_id, caliber, meter_type, manufacturer, status, install_date, initial_reading, current_reading, created_at) VALUES
|
||||
('M001', 1, 'DN15', 'mechanical', '威胜', 'active', '2025-01-01', 0.00, 1200.50, CURRENT_TIMESTAMP),
|
||||
('M002', 2, 'DN20', 'electromagnetic', '三川', 'active', '2025-02-01', 0.00, 2100.75, CURRENT_TIMESTAMP),
|
||||
('M003', 3, 'DN15', 'ultrasonic', '宁水', 'dismantled', '2025-03-01', 0.00, 3200.00, CURRENT_TIMESTAMP),
|
||||
('M004', 4, 'DN25', 'mechanical', '威胜', 'scrapped', '2025-04-01', 0.00, 1500.25, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (meter_no) DO NOTHING;
|
||||
|
||||
-- 插入一些操作记录测试数据
|
||||
INSERT INTO rev_meter_log (meter_id, operation_type, operator_id, operator_name, old_reading, new_reading, remark, created_at) VALUES
|
||||
((SELECT id FROM rev_meter WHERE meter_no = 'M001'), 'install', '1001', '张三', 0.00, 100.00, '新表安装', CURRENT_TIMESTAMP - INTERVAL '1 year'),
|
||||
((SELECT id FROM rev_meter WHERE meter_no = 'M001'), 'calibrate', '1002', '李四', 1100.00, 1100.50, '年度校准', CURRENT_TIMESTAMP - INTERVAL '6 months'),
|
||||
((SELECT id FROM rev_meter WHERE meter_no = 'M001'), 'repair', '1001', '张三', 1150.00, 1150.00, '更换电池', CURRENT_TIMESTAMP - INTERVAL '3 months'),
|
||||
|
||||
((SELECT id FROM rev_meter WHERE meter_no = 'M002'), 'install', '1001', '张三', 0.00, 200.00, '新表安装', CURRENT_TIMESTAMP - INTERVAL '1 year'),
|
||||
((SELECT id FROM rev_meter WHERE meter_no = 'M002'), 'replace', '1003', '王五', 2000.00, 2100.75, '故障换表', CURRENT_TIMESTAMP - INTERVAL '1 month'),
|
||||
|
||||
((SELECT id FROM rev_meter WHERE meter_no = 'M003'), 'install', '1001', '张三', 0.00, 300.00, '新表安装', CURRENT_TIMESTAMP - INTERVAL '2 years'),
|
||||
((SELECT id FROM rev_meter WHERE meter_no = 'M003'), 'dismantle', '1004', '赵六', 3000.00, 3200.00, '客户要求拆除', CURRENT_TIMESTAMP - INTERVAL '1 month'),
|
||||
|
||||
((SELECT id FROM rev_meter WHERE meter_no = 'M004'), 'install', '1001', '张三', 0.00, 400.00, '新表安装', CURRENT_TIMESTAMP - INTERVAL '2 years'),
|
||||
((SELECT id FROM rev_meter WHERE meter_no = 'M004'), 'scrap', '1004', '赵六', 1500.25, NULL, '到期报废', CURRENT_TIMESTAMP - INTERVAL '1 month')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 报装申请表
|
||||
CREATE TABLE IF NOT EXISTS installation_apply (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
|
||||
apply_no VARCHAR(64) NOT NULL COMMENT '申请编号',
|
||||
applicant_name VARCHAR(64) NOT NULL COMMENT '申请人姓名',
|
||||
applicant_phone VARCHAR(20) NOT NULL COMMENT '申请人电话',
|
||||
applicant_id_card VARCHAR(20) DEFAULT NULL COMMENT '申请人身份证号',
|
||||
address VARCHAR(256) NOT NULL COMMENT '用水地址',
|
||||
area VARCHAR(64) NOT NULL COMMENT '所属区域',
|
||||
water_use_type VARCHAR(32) DEFAULT NULL COMMENT '用水类型(居民/商业/工业/其他)',
|
||||
pipe_diameter DECIMAL(10,2) DEFAULT NULL COMMENT '管径(mm)',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PRE_ACCEPT' COMMENT '状态: PRE_ACCEPT/ENGINEERING_APPLY/DISPATCHED/COMPLETED/REJECTED',
|
||||
apply_time DATETIME NOT NULL COMMENT '申请时间',
|
||||
engineering_apply_time DATETIME DEFAULT NULL COMMENT '工程申请时间',
|
||||
dispatch_time DATETIME DEFAULT NULL COMMENT '派单时间',
|
||||
completed_time DATETIME DEFAULT NULL COMMENT '竣工时间',
|
||||
assignee_id BIGINT DEFAULT NULL COMMENT '指派人ID',
|
||||
assignee_name VARCHAR(64) DEFAULT NULL COMMENT '指派人姓名',
|
||||
remarks VARCHAR(512) DEFAULT NULL COMMENT '备注',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_apply_no (apply_no),
|
||||
KEY idx_area (area),
|
||||
KEY idx_status (status),
|
||||
KEY idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='报装申请表';
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@ActiveProfiles("test")
|
||||
class SsoServiceTest {
|
||||
|
||||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@InjectMocks
|
||||
private SsoService ssoService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 重置mock调用计数
|
||||
reset(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoginSuccess() {
|
||||
// 模拟用户存在
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT id, username, real_name, phone, email, status, department_id FROM sys_user WHERE username = ? AND status = 1"),
|
||||
anyString()))
|
||||
.thenReturn(Map.of(
|
||||
"id", "1",
|
||||
"username", "testuser",
|
||||
"real_name", "测试用户",
|
||||
"phone", "13800138000",
|
||||
"email", "test@example.com",
|
||||
"status", "1",
|
||||
"department_id", "1"
|
||||
));
|
||||
|
||||
// 模拟密码查询
|
||||
when(jdbcTemplate.queryForObject(
|
||||
eq("SELECT password FROM sys_user WHERE username = ?"),
|
||||
eq(String.class),
|
||||
anyString()))
|
||||
.thenReturn("{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMy.MrqIeL8nK8s6kJYsKDJ8sR1yxWbKvjm");
|
||||
|
||||
// 模拟插入操作
|
||||
doNothing().when(jdbcTemplate).update(anyString(), any());
|
||||
|
||||
// 测试登录
|
||||
Map<String, String> request = Map.of(
|
||||
"username", "testuser",
|
||||
"password", "password123",
|
||||
"appType", "revenue"
|
||||
);
|
||||
|
||||
R<Map<String, Object>> result = ssoService.login("testuser", "password123", "revenue");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertNotNull(result.getData());
|
||||
assertEquals("testuser", result.getData().get("username"));
|
||||
assertNotNull(result.getData().get("ssoToken"));
|
||||
assertEquals("revenue", result.getData().get("appType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoginUserNotFound() {
|
||||
// 模拟用户不存在
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT id, username, real_name, phone, email, status, department_id FROM sys_user WHERE username = ? AND status = 1"),
|
||||
anyString()))
|
||||
.thenThrow(new RuntimeException("用户不存在"));
|
||||
|
||||
// 测试登录
|
||||
R<Map<String, Object>> result = ssoService.login("nonexistent", "password123", "revenue");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("500", String.valueOf(result.getCode()));
|
||||
assertTrue(result.getMessage().contains("用户不存在"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTokenSuccess() {
|
||||
// 设置活跃token
|
||||
ssoService.activeTokens.put("test-token", "testuser");
|
||||
|
||||
// 模拟用户查询
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT id, username, real_name, phone, email, status FROM sys_user WHERE username = ? AND status = 1"),
|
||||
anyString()))
|
||||
.thenReturn(Map.of(
|
||||
"id", "1",
|
||||
"username", "testuser",
|
||||
"real_name", "测试用户",
|
||||
"phone", "13800138000",
|
||||
"email", "test@example.com",
|
||||
"status", "1"
|
||||
));
|
||||
|
||||
// 测试Token验证
|
||||
Map<String, String> request = Map.of("ssoToken", "test-token");
|
||||
R<Map<String, Object>> result = ssoService.validateToken("test-token");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertNotNull(result.getData());
|
||||
assertEquals("testuser", result.getData().get("username"));
|
||||
assertEquals(true, result.getData().get("tokenValid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTokenInvalid() {
|
||||
// 测试无效token
|
||||
R<Map<String, Object>> result = ssoService.validateToken("invalid-token");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("500", String.valueOf(result.getCode()));
|
||||
assertTrue(result.getMessage().contains("Token无效或已过期"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogout() {
|
||||
// 设置活跃token
|
||||
ssoService.activeTokens.put("test-token", "testuser");
|
||||
|
||||
// 测试登出
|
||||
R<String> result = ssoService.logout("test-token");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertEquals("登出成功", result.getData());
|
||||
|
||||
// 验证token已被移除
|
||||
assertFalse(ssoService.isTokenActive("test-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRegisterAppSuccess() {
|
||||
// 模拟应用名称检查
|
||||
when(jdbcTemplate.queryForObject(
|
||||
eq("SELECT COUNT(*) FROM app_registry WHERE app_name = ?"),
|
||||
eq(Integer.class),
|
||||
anyString()))
|
||||
.thenReturn(0);
|
||||
|
||||
// 模拟插入操作
|
||||
when(jdbcTemplate.update(anyString(), any()))
|
||||
.thenReturn(1);
|
||||
|
||||
// 测试应用注册
|
||||
Map<String, String> request = Map.of(
|
||||
"appName", "测试应用",
|
||||
"appKey", "test-app-key",
|
||||
"appSecret", "test-app-secret",
|
||||
"redirectUri", "http://localhost:3000/callback",
|
||||
"description", "测试应用描述"
|
||||
);
|
||||
|
||||
R<Map<String, Object>> result = ssoService.registerApp(
|
||||
"测试应用", "test-app-key", "test-app-secret",
|
||||
"http://localhost:3000/callback", "测试应用描述"
|
||||
);
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertNotNull(result.getData());
|
||||
assertEquals("测试应用", result.getData().get("appName"));
|
||||
assertNotNull(result.getData().get("appKey"));
|
||||
assertNotNull(result.getData().get("appSecret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRegisterAppDuplicateName() {
|
||||
// 模拟应用名称已存在
|
||||
when(jdbcTemplate.queryForObject(
|
||||
eq("SELECT COUNT(*) FROM app_registry WHERE app_name = ?"),
|
||||
eq(Integer.class),
|
||||
anyString()))
|
||||
.thenReturn(1);
|
||||
|
||||
// 测试应用注册
|
||||
R<Map<String, Object>> result = ssoService.registerApp(
|
||||
"重复应用", "test-app-key", "test-app-secret",
|
||||
"http://localhost:3000/callback", "测试应用描述"
|
||||
);
|
||||
|
||||
// 验证结果
|
||||
assertEquals("500", String.valueOf(result.getCode()));
|
||||
assertTrue(result.getMessage().contains("应用名称已存在"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateAppSuccess() {
|
||||
// 模拟应用查询
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1"),
|
||||
anyString(), anyString()))
|
||||
.thenReturn(Map.of(
|
||||
"app_key", "test-app-key",
|
||||
"app_name", "测试应用",
|
||||
"redirect_uri", "http://localhost:3000/callback",
|
||||
"status", "1"
|
||||
));
|
||||
|
||||
// 测试应用验证
|
||||
Map<String, String> request = Map.of(
|
||||
"appKey", "test-app-key",
|
||||
"appSecret", "test-app-secret"
|
||||
);
|
||||
|
||||
R<Map<String, Object>> result = ssoService.validateApp("test-app-key", "test-app-secret");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertNotNull(result.getData());
|
||||
assertEquals("test-app-key", result.getData().get("appKey"));
|
||||
assertEquals("测试应用", result.getData().get("appName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateAppFailure() {
|
||||
// 模拟应用不存在
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1"),
|
||||
anyString(), anyString()))
|
||||
.thenThrow(new RuntimeException("应用不存在"));
|
||||
|
||||
// 测试应用验证
|
||||
R<Map<String, Object>> result = ssoService.validateApp("invalid-app-key", "invalid-app-secret");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("500", String.valueOf(result.getCode()));
|
||||
assertTrue(result.getMessage().contains("应用验证失败"));
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.water.revenue.service.enhanced;
|
||||
|
||||
import com.water.revenue.service.enhanced.EnhancedRemoteReadingService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 增强版远传集抄服务测试
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
public class EnhancedRemoteReadingServiceTest {
|
||||
|
||||
@Autowired
|
||||
private EnhancedRemoteReadingService enhancedService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 清理测试数据
|
||||
jdbcTemplate.update("DELETE FROM rev_reading WHERE read_type = 'test'");
|
||||
jdbcTemplate.update("DELETE FROM rev_batch_report WHERE report_id LIKE 'TEST_%'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBatchReadSingleArea() {
|
||||
// 测试单区域批量抄表
|
||||
Map<String, Object> result = enhancedService.enhancedBatchRead(List.of("测试区域"));
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("测试区域", ((List<String>) result.get("areas")).get(0));
|
||||
assertTrue((Integer) result.get("totalCount") >= 0);
|
||||
assertTrue((Integer) result.get("successCount") >= 0);
|
||||
assertTrue((Integer) result.get("failedCount") >= 0);
|
||||
assertTrue((Integer) result.get("abnormalCount") >= 0);
|
||||
|
||||
assertNotNull(result.get("period"));
|
||||
assertNotNull(result.get("reportId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBatchReadMultiArea() {
|
||||
// 测试多区域批量抄表
|
||||
List<String> areas = List.of("测试区域1", "测试区域2");
|
||||
Map<String, Object> result = enhancedService.enhancedBatchRead(areas);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(2, ((List<String>) result.get("areas")).size());
|
||||
|
||||
// 检查每个区域的统计信息
|
||||
assertTrue(result.containsKey("area_测试区域1"));
|
||||
assertTrue(result.containsKey("area_测试区域2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateReading() {
|
||||
// 测试读数校验逻辑
|
||||
|
||||
// 模拟DN80水表
|
||||
Map<String, Object> meterDN80 = Map.of(
|
||||
"meter_no", "TEST_DN80_001",
|
||||
"caliber", "DN80",
|
||||
"current_reading", new BigDecimal("1000.00")
|
||||
);
|
||||
|
||||
// 测试正常递增
|
||||
Map<String, Object> validation1 = enhancedService.readSingleMeter(meterDN80, "测试区域");
|
||||
assertEquals("success", validation1.get("status"));
|
||||
assertEquals(new BigDecimal("1000.00"), validation1.get("prevReading"));
|
||||
assertTrue(new BigDecimal("1000.00").compareTo((BigDecimal) validation1.get("currReading")) <= 0);
|
||||
|
||||
// 测试异常递减(应该标记为异常但仍成功记录)
|
||||
Map<String, Object> meterDecrease = Map.of(
|
||||
"meter_no", "TEST_DECREASE_001",
|
||||
"caliber", "DN80",
|
||||
"current_reading", new BigDecimal("2000.00")
|
||||
);
|
||||
enhancedService.readSingleMeter(meterDecrease, "测试区域");
|
||||
enhancedService.readSingleMeter(meterDecrease, "测试区域"); // 第二次递减
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLargeMeterMonitoring() {
|
||||
// 测试大表监控功能
|
||||
Map<String, Object> result = enhancedService.largeMeterEnhancedMonitor();
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue((Integer) result.get("totalCount") >= 0);
|
||||
assertNotNull(result.get("monitors"));
|
||||
assertNotNull(result.get("alarms"));
|
||||
assertTrue(((List<?>) result.get("monitors")).size() >= 0);
|
||||
assertTrue(((List<?>) result.get("alarms")).size() >= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMaxIncrementByCaliber() {
|
||||
// 测试不同管径的最大合理增量
|
||||
|
||||
// DN15 小表应该限制在10立方米以内
|
||||
assertTrue(enhancedService.getMaxIncrementByCaliber("DN15").compareTo(new BigDecimal("10")) <= 0);
|
||||
|
||||
// DN80 大表应该允许更大的增量
|
||||
assertTrue(enhancedService.getMaxIncrementByCaliber("DN80").compareTo(new BigDecimal("500")) <= 0);
|
||||
|
||||
// DN150 超大表允许更大的增量
|
||||
assertTrue(enhancedService.getMaxIncrementByCaliber("DN150").compareTo(new BigDecimal("1500")) <= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateReport() {
|
||||
// 测试生成抄表报告
|
||||
List<String> areas = List.of("测试区域");
|
||||
Map<String, Object> result = enhancedService.enhancedBatchRead(areas);
|
||||
|
||||
assertNotNull(result.get("reportId"));
|
||||
assertNotNull(result.get("generatedAt"));
|
||||
|
||||
// 验证报告确实保存到数据库
|
||||
String reportId = (String) result.get("reportId");
|
||||
Map<String, Object> dbReport = jdbcTemplate.queryForMap(
|
||||
"SELECT * FROM rev_batch_report WHERE report_id = ?", reportId);
|
||||
|
||||
assertEquals(reportId, dbReport.get("report_id"));
|
||||
assertEquals(areas.get(0), ((List<?>) dbReport.get("areas")).get(0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user