Merge remote-tracking branch 'origin/feature/issue-48'

# Conflicts:
#	frontend/src/router/index.ts
#	wm-production/pom.xml
This commit is contained in:
2026-06-15 08:33:46 +08:00
235 changed files with 23417 additions and 225 deletions
+141 -8
View File
@@ -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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
+198
View File
@@ -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
+193 -11
View File
@@ -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("应用验证失败"));
}
}
@@ -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));
}
}