Commit e6ce768b authored by lixuan's avatar lixuan

feat: 需求

parent 7d213d86
Pipeline #147221 failed with stages
in 0 seconds
package com.ruoyi.web.controller.fieldchangerecord;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeRecordQuery;
import com.ruoyi.system.service.fieldchangerecord.DataFieldChangeRecordService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 字段级变更统计对外接口。
*
* <p>房源侧和经营主体侧完全分开的 4 个 GET 接口:</p>
* <ul>
* <li>{@code /houseResource}:HR 侧明细分页</li>
* <li>{@code /houseResource/summary}:HR 侧按字段 + 操作的计数</li>
* <li>{@code /businessEntity}:BE 侧明细分页(展开每个关联房源)</li>
* <li>{@code /businessEntity/summary}:BE 侧汇总(按日志去重)</li>
* </ul>
*
* <p>入参形态详见 {@link FieldChangeRecordQuery}。{@code changeStartTime /
* changeEndTime} 必填;{@code wgCodes} 由 Service 层按当前登录人注入,前端不传。</p>
*/
@RestController
@RequestMapping("/api/house/fieldChangeRecords")
public class DataFieldChangeRecordController {
private final DataFieldChangeRecordService service;
public DataFieldChangeRecordController(DataFieldChangeRecordService service) {
this.service = service;
}
@GetMapping("/houseResource")
public AjaxResult pageHouseResource(FieldChangeRecordQuery query) {
return AjaxResult.success(service.pageHouseResourceFieldChanges(query));
}
@GetMapping("/houseResource/summary")
public AjaxResult summaryHouseResource(FieldChangeRecordQuery query) {
return AjaxResult.success(service.summaryHouseResourceFieldChanges(query));
}
@GetMapping("/businessEntity")
public AjaxResult pageBusinessEntity(FieldChangeRecordQuery query) {
return AjaxResult.success(service.pageBusinessEntityFieldChanges(query));
}
@GetMapping("/businessEntity/summary")
public AjaxResult summaryBusinessEntity(FieldChangeRecordQuery query) {
return AjaxResult.success(service.summaryBusinessEntityFieldChanges(query));
}
}
...@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor; ...@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.interceptor.mybatis.DataChangeLogInterceptor; import com.ruoyi.framework.interceptor.mybatis.DataChangeLogInterceptor;
import com.ruoyi.framework.interceptor.mybatis.DataFieldChangeLogInterceptor;
import org.apache.ibatis.io.VFS; import org.apache.ibatis.io.VFS;
import org.apache.ibatis.plugin.Interceptor; import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionFactory;
...@@ -127,6 +128,7 @@ public class MyBatisConfig ...@@ -127,6 +128,7 @@ public class MyBatisConfig
VFS.addImplClass(SpringBootVFS.class); VFS.addImplClass(SpringBootVFS.class);
Interceptor[] interceptors = { Interceptor[] interceptors = {
new DataChangeLogInterceptor(), new DataChangeLogInterceptor(),
new DataFieldChangeLogInterceptor(),
new PaginationInterceptor() new PaginationInterceptor()
}; };
final MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean(); final MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
......
package com.ruoyi.framework.interceptor.mybatis;
import com.ruoyi.system.domain.changerecord.enums.OperationType;
import com.ruoyi.system.domain.fieldchangerecord.DataFieldChangeRecord;
import com.ruoyi.system.domain.fieldchangerecord.annotation.DataFieldChangeLog;
import com.ruoyi.system.domain.fieldchangerecord.enums.FieldCode;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 字段级变更日志拦截器。
*
* <p>和 {@link DataChangeLogInterceptor} 并行工作,拦截
* {@link Executor#update(MappedStatement, Object)} 调用,读取 Mapper 方法上的
* {@link DataFieldChangeLog} 注解,对声明的字段做前后值 diff 后写入
* {@code data_field_change_record} 表。</p>
*
* <p>关键约束:</p>
* <ol>
* <li>只处理 {@link OperationType#INSERT} 和 {@link OperationType#UPDATE},
* DELETE 直接跳过(继续走主体级日志)。</li>
* <li>UPDATE 场景必须配置 {@code preLoadMapper} 捞旧记录;否则无法 diff 直接跳过。</li>
* <li><strong>Partial update 语义</strong>:入参里字段值为 null 视为"未修改",不 diff、不写日志。</li>
* <li>受影响行数为 0 时跳过日志写入。</li>
* <li>INSERT 且字段值为 null 时不写日志(避免一条 insert 炸出 N 条空日志)。</li>
* <li>{@link FieldCode#BE_YEAR_SELL} 的值格式化为 {@code "year=2025;value=523.50"}。</li>
* <li>BigDecimal 比较采用 {@code compareTo},避免 scale 不同导致误判(1.0 vs 1.00)。</li>
* <li>日志写入失败抛出异常,业务事务整体回滚。</li>
* </ol>
*/
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class DataFieldChangeLogInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(DataFieldChangeLogInterceptor.class);
private static final String LOG_INSERT_STATEMENT_ID =
"com.ruoyi.system.mapper.fieldchangerecord.DataFieldChangeRecordMapper.batchInsert";
private static final DataFieldChangeLog[] EMPTY = new DataFieldChangeLog[0];
/** 按 MappedStatement id 缓存注解列表。 */
private final Map<String, DataFieldChangeLog[]> annotationCache = new ConcurrentHashMap<>();
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object param = args[1];
DataFieldChangeLog[] annotations = resolveAnnotations(ms.getId());
if (annotations.length == 0) {
return invocation.proceed();
}
Executor executor = (Executor) invocation.getTarget();
// UPDATE 场景先捞旧值,按 preLoadMapper 缓存避免重复查询。
Map<String, Map<String, Object>> preloadByMapper = runPreload(annotations, param, executor, ms);
Object result = invocation.proceed();
if (result instanceof Integer && (Integer) result == 0) {
return result;
}
List<DataFieldChangeRecord> records = buildRecords(annotations, param, preloadByMapper);
if (records.isEmpty()) {
return result;
}
try {
MappedStatement logMs = ms.getConfiguration().getMappedStatement(LOG_INSERT_STATEMENT_ID);
Map<String, Object> logParam = new HashMap<>(2);
logParam.put("list", records);
executor.update(logMs, logParam);
} catch (Exception e) {
log.error("写入 data_field_change_record 失败,业务事务将回滚。ms={}", ms.getId(), e);
throw e;
}
return result;
}
@Override
public Object plugin(Object target) {
if (target instanceof Executor) {
return org.apache.ibatis.plugin.Plugin.wrap(target, this);
}
return target;
}
@Override
public void setProperties(java.util.Properties properties) {
}
private DataFieldChangeLog[] resolveAnnotations(String statementId) {
return annotationCache.computeIfAbsent(statementId, this::loadAnnotations);
}
private DataFieldChangeLog[] loadAnnotations(String statementId) {
int dot = statementId.lastIndexOf('.');
if (dot <= 0) {
return EMPTY;
}
String mapperClassName = statementId.substring(0, dot);
String methodName = statementId.substring(dot + 1);
try {
Class<?> mapperClass = Class.forName(mapperClassName);
for (Method m : mapperClass.getDeclaredMethods()) {
if (!m.getName().equals(methodName)) {
continue;
}
DataFieldChangeLog[] annos = m.getAnnotationsByType(DataFieldChangeLog.class);
if (annos.length > 0) {
return annos;
}
}
} catch (ClassNotFoundException ignore) {
}
return EMPTY;
}
/**
* 对所有 UPDATE 注解执行 preload 查询,返回 {@code preLoadMapperFullName -> pk -> oldEntity} 三层结构。
* 同一 preLoadMapper 只查一次。
*/
private Map<String, Map<String, Object>> runPreload(DataFieldChangeLog[] annotations, Object param,
Executor executor, MappedStatement ms) throws Exception {
Map<String, Map<String, Object>> results = new HashMap<>();
for (DataFieldChangeLog anno : annotations) {
if (anno.operation() != OperationType.UPDATE) {
continue;
}
String preLoad = anno.preLoadMapper();
if (preLoad.isEmpty() || results.containsKey(preLoad)) {
continue;
}
MappedStatement preLoadMs = ms.getConfiguration().getMappedStatement(preLoad);
@SuppressWarnings("rawtypes")
List queryResult = executor.query(preLoadMs, param, RowBounds.DEFAULT, (ResultHandler) null);
Map<String, Object> byPk = new HashMap<>();
if (queryResult != null) {
for (Object old : queryResult) {
if (old == null) {
continue;
}
Object pk = getProperty(old, "id");
if (pk != null) {
byPk.put(String.valueOf(pk), old);
}
}
}
results.put(preLoad, byPk);
}
return results;
}
private List<DataFieldChangeRecord> buildRecords(DataFieldChangeLog[] annotations, Object param,
Map<String, Map<String, Object>> preloadByMapper) {
Date now = new Date();
List<DataFieldChangeRecord> records = new ArrayList<>();
for (DataFieldChangeLog anno : annotations) {
if (anno.operation() != OperationType.INSERT && anno.operation() != OperationType.UPDATE) {
continue;
}
// UPDATE 必须配 preLoadMapper,否则无法 diff。
Map<String, Object> oldByPk = Collections.emptyMap();
if (anno.operation() == OperationType.UPDATE) {
if (anno.preLoadMapper().isEmpty()) {
log.warn("@DataFieldChangeLog UPDATE 未配 preLoadMapper,跳过 diff。subjectType={}", anno.subjectType());
continue;
}
oldByPk = preloadByMapper.getOrDefault(anno.preLoadMapper(), Collections.emptyMap());
}
List<Subject> subjects = resolveSubjects(param, anno.idExpr(), oldByPk);
for (Subject subject : subjects) {
if (subject.subjectId == null || subject.subjectId.isEmpty() || subject.source == null) {
continue;
}
Object sourcePk = getProperty(subject.source, "id");
Object oldSnapshot = sourcePk == null ? null : oldByPk.get(String.valueOf(sourcePk));
for (FieldCode field : anno.fields()) {
DataFieldChangeRecord rec = buildOneRecord(anno, subject, oldSnapshot, field, now);
if (rec != null) {
records.add(rec);
}
}
}
}
return records;
}
private DataFieldChangeRecord buildOneRecord(DataFieldChangeLog anno, Subject subject,
Object oldSnapshot, FieldCode field, Date now) {
Object newRaw = getProperty(subject.source, field.getPropertyName());
Object oldRaw = oldSnapshot == null ? null : getProperty(oldSnapshot, field.getPropertyName());
if (anno.operation() == OperationType.INSERT) {
if (newRaw == null) {
return null;
}
} else {
// UPDATE:partial update 语义——newRaw 为 null 视为未修改。
if (newRaw == null) {
return null;
}
if (valuesEqual(newRaw, oldRaw)) {
return null;
}
}
String oldStr;
String newStr;
if (field == FieldCode.BE_YEAR_SELL) {
Object newYear = getProperty(subject.source, "year");
Object oldYear = oldSnapshot == null ? null : getProperty(oldSnapshot, "year");
newStr = formatYearSell(newYear, newRaw);
oldStr = (oldSnapshot == null || oldRaw == null) ? null : formatYearSell(oldYear, oldRaw);
} else {
newStr = String.valueOf(newRaw);
oldStr = oldRaw == null ? null : String.valueOf(oldRaw);
}
DataFieldChangeRecord r = new DataFieldChangeRecord();
r.setId(UUID.randomUUID().toString().replace("-", ""));
r.setSubjectType(anno.subjectType().name());
r.setSubjectId(subject.subjectId);
r.setFieldCode(field.name());
r.setOldValue(oldStr);
r.setNewValue(newStr);
r.setOperationType(anno.operation().getCode());
r.setOperationTime(now);
return r;
}
private String formatYearSell(Object year, Object value) {
return "year=" + (year == null ? "" : String.valueOf(year))
+ ";value=" + (value == null ? "" : String.valueOf(value));
}
/**
* 解析 idExpr,返回 (subjectId, 值源对象) 列表。
*
* <p>值源对象用于后续反射取字段新值。对于 {@code list[*].xxx} 批量表达式,
* 每个列表元素分别作为独立的值源。</p>
*
* <p>特殊:{@code @preload[*].xxx} 用于 partial update 场景(例如
* {@code updateBusinessEntitySell} 入参不含 {@code businessEntityInfoId} 时,
* 需要从预加载的旧记录拿 subject_id)。此时 source 仍是 {@code param},因为字段
* 新值必然来自业务写操作的入参,而 pk 匹配依然能将 source 和 oldSnapshot 挂钩。
* 该特殊路径只适用于"单实体 update"场景(preload 结果大小为 1)。</p>
*/
private List<Subject> resolveSubjects(Object param, String idExpr, Map<String, Object> oldByPk) {
if (idExpr == null || idExpr.isEmpty() || param == null) {
return Collections.emptyList();
}
int starIdx = idExpr.indexOf("[*]");
if (starIdx >= 0) {
String listKey = idExpr.substring(0, starIdx);
String afterStar = idExpr.substring(starIdx + 3);
if (afterStar.startsWith(".")) {
afterStar = afterStar.substring(1);
}
if ("@preload".equals(listKey)) {
if (oldByPk == null || oldByPk.isEmpty()) {
return Collections.emptyList();
}
List<Subject> subjects = new ArrayList<>(oldByPk.size());
for (Object old : oldByPk.values()) {
Object idVal = afterStar.isEmpty() ? old : getProperty(old, afterStar);
if (idVal == null) {
continue;
}
subjects.add(new Subject(String.valueOf(idVal), param));
}
return subjects;
}
Object v = getProperty(param, listKey);
if (!(v instanceof List)) {
return Collections.emptyList();
}
List<?> list = (List<?>) v;
List<Subject> subjects = new ArrayList<>(list.size());
for (Object item : list) {
if (item == null) {
continue;
}
Object idVal = afterStar.isEmpty() ? item : getProperty(item, afterStar);
if (idVal == null) {
continue;
}
subjects.add(new Subject(String.valueOf(idVal), item));
}
return subjects;
}
Object val = getProperty(param, idExpr);
if (val == null) {
return Collections.emptyList();
}
return Collections.singletonList(new Subject(String.valueOf(val), param));
}
private boolean valuesEqual(Object a, Object b) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
if (a instanceof BigDecimal && b instanceof BigDecimal) {
return ((BigDecimal) a).compareTo((BigDecimal) b) == 0;
}
return Objects.equals(a, b);
}
private Object getProperty(Object obj, String key) {
if (obj == null || key == null || key.isEmpty()) {
return null;
}
if (obj instanceof Map) {
return ((Map<?, ?>) obj).get(key);
}
try {
String capitalized = Character.toUpperCase(key.charAt(0)) + key.substring(1);
String getter = "get" + capitalized;
String booleanGetter = "is" + capitalized;
for (Method m : obj.getClass().getMethods()) {
if (m.getParameterCount() != 0) {
continue;
}
if (m.getName().equals(getter) || m.getName().equals(booleanGetter)) {
return m.invoke(obj);
}
}
Field f = findField(obj.getClass(), key);
if (f != null) {
f.setAccessible(true);
return f.get(obj);
}
} catch (Exception ignore) {
}
return null;
}
private Field findField(Class<?> cls, String name) {
Class<?> c = cls;
while (c != null && c != Object.class) {
for (Field f : c.getDeclaredFields()) {
if (f.getName().equals(name)) {
return f;
}
}
c = c.getSuperclass();
}
return null;
}
/** 一个 (subject_id, 值源对象) 二元组。 */
private static final class Subject {
final String subjectId;
final Object source;
Subject(String subjectId, Object source) {
this.subjectId = subjectId;
this.source = source;
}
}
}
package com.ruoyi.system.domain.fieldchangerecord;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 字段级变更记录。
*
* <p>对应数据库表 {@code data_field_change_record}。</p>
*
* <ul>
* <li>{@link #subjectType} 取值见 {@link com.ruoyi.system.domain.changerecord.enums.SubjectType}。</li>
* <li>{@link #fieldCode} 取值见
* {@link com.ruoyi.system.domain.fieldchangerecord.enums.FieldCode}。</li>
* <li>{@link #operationType} 仅会是 1(INSERT)或 2(UPDATE);DELETE 不进本表。</li>
* <li>{@link #oldValue} / {@link #newValue} 对于图片字段直接存真实 URL;对于
* {@code BE_YEAR_SELL} 存 {@code "year=2025;value=523.50"} 形式。</li>
* </ul>
*/
@Data
public class DataFieldChangeRecord implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String subjectType;
private String subjectId;
private String fieldCode;
private String oldValue;
private String newValue;
private Integer operationType;
private Date operationTime;
}
package com.ruoyi.system.domain.fieldchangerecord.annotation;
import com.ruoyi.system.domain.changerecord.enums.OperationType;
import com.ruoyi.system.domain.changerecord.enums.SubjectType;
import com.ruoyi.system.domain.fieldchangerecord.enums.FieldCode;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 字段级变更日志注解。
*
* <p>标注在 Mapper 方法上,用于声明该写操作需要对指定 {@link #fields()} 做前后值 diff,
* 并将真变更写入 {@code data_field_change_record} 表。和主体级 {@code @DataChangeLog}
* 并行存在,互不影响。</p>
*
* <h3>idExpr 表达式语法</h3>
* <p>与 {@code @DataChangeLog} 完全一致:</p>
* <ul>
* <li>{@code "fieldName"} — 从参数按属性名取值;</li>
* <li>{@code "list[*].fieldName"} — 批量操作场景;</li>
* <li>{@code "@preload[*].fieldName"} — 从 {@link #preLoadMapper()} 查询结果中取值。</li>
* </ul>
*
* <h3>preLoadMapper</h3>
* <p><strong>UPDATE 场景必需</strong>:diff 需要知道旧值,拦截器会在执行业务 SQL 前
* 先调用 preLoadMapper 查出旧记录。INSERT 场景 preLoadMapper 留空即可。</p>
*
* <h3>Partial update 语义</h3>
* <p>UPDATE 时,如果 new value(从参数里取)为 {@code null},拦截器会跳过该字段 diff
* 不产生日志(视为"未修改"),兼容 MyBatis {@code <set>} 标签只拼非 null 字段的写法。</p>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(DataFieldChangeLogs.class)
public @interface DataFieldChangeLog {
/** 主体类型。必须和 {@link #fields()} 里每个 FieldCode 的 subjectType 一致。 */
SubjectType subjectType();
/** 操作类型。仅支持 INSERT 和 UPDATE;DELETE 不进本表。 */
OperationType operation();
/** subject_id 取值表达式。 */
String idExpr();
/** 本次需要 diff 的字段清单。 */
FieldCode[] fields();
/**
* UPDATE 场景下,用于捞旧记录的查询 Mapper 方法全限定名。
* INSERT 场景留空。
*
* <p>预加载的返回值应该是与主体同构的对象列表(每个元素是 HouseResource /
* BusinessEntityInfo / BusinessEntitySell 等),拦截器会按 id 匹配找到对应旧值。</p>
*/
String preLoadMapper() default "";
}
package com.ruoyi.system.domain.fieldchangerecord.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* {@link DataFieldChangeLog} 的容器注解。
*
* <p>通过 {@code @Repeatable} 机制,在同一个方法上写多个
* {@link DataFieldChangeLog} 会被自动包装成此容器注解。</p>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataFieldChangeLogs {
DataFieldChangeLog[] value();
}
package com.ruoyi.system.domain.fieldchangerecord.enums;
import com.ruoyi.system.domain.changerecord.enums.SubjectType;
/**
* 字段级变更的业务语义字段码。
*
* <p>一共 10 个字段,覆盖需求方指定的全部统计维度:</p>
* <ul>
* <li>HR 侧 6 个:业务场所状态 / 面积 / 产权人联系方式 / 租金单价 / 场所图片 / 营业执照图片</li>
* <li>BE 侧 4 个:名称 / 负责人联系方式 / 工作人员数量 / 年度销售额</li>
* </ul>
*
* <p>{@link #propertyName} 是对应 Java 实体 (HouseResource / BusinessEntityInfo /
* BusinessEntitySell) 上的属性名,拦截器据此反射取值做 diff。</p>
*
* <p>{@code BE_YEAR_SELL} 较特殊:subject_id 取 {@code business_entity_info_id},
* old / new value 序列化为 {@code year=2025;value=523.50} 形式,拦截器在 diff 时按
* 这个格式组装。</p>
*/
public enum FieldCode {
// --- BUSINESS_ENTITY_INFO ---
BE_NAME(SubjectType.BUSINESS_ENTITY_INFO, FieldValueType.TEXT,
"name", "经营主体名称"),
BE_PRINCIPAL_TEL(SubjectType.BUSINESS_ENTITY_INFO, FieldValueType.TEXT,
"principalTel", "负责人联系方式"),
BE_WORKER_NUMBER(SubjectType.BUSINESS_ENTITY_INFO, FieldValueType.NUMBER,
"workerNumber", "工作人员数量"),
/**
* 年度销售额。subject 挂在 BUSINESS_ENTITY_INFO 上,值以
* {@code year=2025;value=523.50} 形式存储。
*/
BE_YEAR_SELL(SubjectType.BUSINESS_ENTITY_INFO, FieldValueType.NUMBER,
"yearSell", "年度销售额"),
// --- HOUSE_RESOURCE ---
HR_BUSINESS_STATUS(SubjectType.HOUSE_RESOURCE, FieldValueType.ENUM,
"businessStatus", "经营场所状态"),
HR_AREA(SubjectType.HOUSE_RESOURCE, FieldValueType.NUMBER,
"houseArea", "经营场所面积"),
HR_EQUITY_TEL(SubjectType.HOUSE_RESOURCE, FieldValueType.TEXT,
"houseResourceEquityTel", "产权人联系方式"),
HR_UNIT_PRICE(SubjectType.HOUSE_RESOURCE, FieldValueType.NUMBER,
"unitPrice", "租金"),
HR_IMAGE(SubjectType.HOUSE_RESOURCE, FieldValueType.IMAGE,
"houseResourceUrl", "经营场所图片"),
HR_LICENSE_IMAGE(SubjectType.HOUSE_RESOURCE, FieldValueType.IMAGE,
"businessLicenseUrl", "营业执照图片");
private final SubjectType subjectType;
private final FieldValueType valueType;
/** 对应 Java 实体上的属性名。拦截器据此反射取值。 */
private final String propertyName;
/** 中文 label,接口层返回给前端展示。 */
private final String label;
FieldCode(SubjectType subjectType, FieldValueType valueType, String propertyName, String label) {
this.subjectType = subjectType;
this.valueType = valueType;
this.propertyName = propertyName;
this.label = label;
}
public SubjectType getSubjectType() {
return subjectType;
}
public FieldValueType getValueType() {
return valueType;
}
public String getPropertyName() {
return propertyName;
}
public String getLabel() {
return label;
}
/** 根据 code 字符串反查枚举;未知 code 返回 null(不抛异常,便于前端容错)。 */
public static FieldCode fromCode(String code) {
if (code == null) {
return null;
}
for (FieldCode f : values()) {
if (f.name().equals(code)) {
return f;
}
}
return null;
}
}
package com.ruoyi.system.domain.fieldchangerecord.enums;
/**
* 字段值的展示类型,前端据此选择渲染分支。
*/
public enum FieldValueType {
/** 纯文本,前端直接展示 "老值 -> 新值"。 */
TEXT,
/** 枚举,前端需要字典翻译后再展示。 */
ENUM,
/** 数值。 */
NUMBER,
/** 图片 URL(可能是逗号分隔多图),前端渲染缩略图对比。 */
IMAGE;
}
package com.ruoyi.system.domain.fieldchangerecord.vo;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.util.Date;
import java.util.List;
/**
* 字段级变更 明细 / 汇总 查询入参。
*
* <p>房源侧和经营主体侧共用此结构,在 Service 层由不同方法分流:</p>
* <ul>
* <li>房源侧:{@code hrTypes / two / three / four} 作用在 {@code house_resource} 自身;</li>
* <li>经营主体侧:通过 mapping → house_resource 关联过滤,即"至少关联到一个匹配条件房源"
* 的经营主体才会被计入。</li>
* </ul>
*/
@Data
public class FieldChangeRecordQuery {
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date changeStartTime;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date changeEndTime;
/** 字段码多选。为空表示不限(后端默认带入当前主体侧全部字段)。 */
private List<String> fieldCodes;
/** 操作类型多选,取值 1(新增)/ 2(修改)。为空表示不限。 */
private List<Integer> operationTypes;
/** 房源类型多选 1/4/5/6/7。 */
private List<Integer> hrTypes;
private String two;
private String three;
private String four;
/** 当前登录人网格权限范围,由 Service 层注入,前端无需传。 */
private List<String> wgCodes;
/** 明细分页参数;汇总接口忽略。 */
private Integer pageNum;
private Integer pageSize;
}
package com.ruoyi.system.domain.fieldchangerecord.vo;
import lombok.Data;
import java.util.Date;
/**
* 字段级变更明细返回结构。
*
* <p>Mapper XML 已经把所需的 JOIN 结果(关联房源的 type / 网格 / 地址)铺平到此 VO,
* 前端直接使用。</p>
*/
@Data
public class FieldChangeRecordVO {
/** 日志记录 id。 */
private String id;
/** HOUSE_RESOURCE / BUSINESS_ENTITY_INFO。 */
private String subjectType;
/** 主体 id(房源 id 或经营主体 id)。 */
private String subjectId;
/** 主体展示名:HR 侧为"地址 + 房号"拼接;BE 侧为经营主体 name。 */
private String subjectName;
/** 字段码。 */
private String fieldCode;
/** 字段中文 label,由 Service 层填充(从 FieldCode 枚举读)。 */
private String fieldLabel;
/** 值类型,TEXT/ENUM/NUMBER/IMAGE,由 Service 层填充。 */
private String valueType;
private String oldValue;
private String newValue;
/** 1 新增 / 2 修改。 */
private Integer operationType;
private Date operationTime;
/**
* 关联房源 type(BE 侧一条日志可能对应多行,每行对应一个关联房源)。
* HR 侧就是主体自己的 type。
*/
private Integer hrType;
private String two;
private String three;
private String four;
/** 关联房源 id(BE 侧用于 drilldown)。HR 侧等于 subjectId。 */
private String associatedHouseResourceId;
/** 关联房源展示名(地址 + 房号)。HR 侧与 subjectName 重复,可前端忽略。 */
private String associatedHouseResourceName;
}
package com.ruoyi.system.domain.fieldchangerecord.vo;
import lombok.Data;
/**
* 字段级变更汇总一行。
*
* <p>返回结构:按 (fieldCode, operationType) 分组的计数。Service 层在此基础上补齐
* 中文 {@code label} 和 {@code valueType}。</p>
*/
@Data
public class FieldChangeSummaryRow {
private String fieldCode;
private String fieldLabel;
private String valueType;
/** 1 新增 / 2 修改。 */
private Integer operationType;
private Long count;
}
package com.ruoyi.system.mapper.fieldchangerecord;
import com.ruoyi.system.domain.fieldchangerecord.DataFieldChangeRecord;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 字段级变更记录 Mapper。
*/
public interface DataFieldChangeRecordMapper {
/**
* 批量插入字段级变更记录。
*
* <p>本方法不要标注 {@link com.ruoyi.system.domain.fieldchangerecord.annotation.DataFieldChangeLog},
* 否则会无限递归。</p>
*/
int batchInsert(@Param("list") List<DataFieldChangeRecord> list);
}
package com.ruoyi.system.mapper.fieldchangerecord;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeRecordQuery;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeRecordVO;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeSummaryRow;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 字段级变更查询 Mapper。
*
* <p>和 {@link DataFieldChangeRecordMapper}(只负责 batchInsert)分开,职责清晰:
* 写入用拦截器走 batchInsert;读取由此 Mapper 承担,包含 HR 侧和 BE 侧两条
* 完全独立的查询路径。</p>
*/
public interface DataFieldChangeRecordQueryMapper {
/** HR 侧明细,分页。走 data_field_change_record JOIN house_resource。 */
IPage<FieldChangeRecordVO> selectHouseResourceFieldChanges(
IPage<FieldChangeRecordVO> page,
@Param("query") FieldChangeRecordQuery query);
/** HR 侧汇总,按 (fieldCode, operationType) 分组。 */
List<FieldChangeSummaryRow> selectHouseResourceFieldChangeSummary(
@Param("query") FieldChangeRecordQuery query);
/** BE 侧明细,分页。走 data_field_change_record JOIN bei + mapping + hr。 */
IPage<FieldChangeRecordVO> selectBusinessEntityFieldChanges(
IPage<FieldChangeRecordVO> page,
@Param("query") FieldChangeRecordQuery query);
/** BE 侧汇总。为避免 JOIN 膨胀,对日志 id 做 distinct 计数。 */
List<FieldChangeSummaryRow> selectBusinessEntityFieldChangeSummary(
@Param("query") FieldChangeRecordQuery query);
}
...@@ -3,6 +3,8 @@ package com.ruoyi.system.mapper.house; ...@@ -3,6 +3,8 @@ package com.ruoyi.system.mapper.house;
import com.ruoyi.system.domain.changerecord.annotation.DataChangeLog; import com.ruoyi.system.domain.changerecord.annotation.DataChangeLog;
import com.ruoyi.system.domain.changerecord.enums.OperationType; import com.ruoyi.system.domain.changerecord.enums.OperationType;
import com.ruoyi.system.domain.changerecord.enums.SubjectType; import com.ruoyi.system.domain.changerecord.enums.SubjectType;
import com.ruoyi.system.domain.fieldchangerecord.annotation.DataFieldChangeLog;
import com.ruoyi.system.domain.fieldchangerecord.enums.FieldCode;
import com.ruoyi.system.domain.house.BusinessEntityInfo; import com.ruoyi.system.domain.house.BusinessEntityInfo;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
...@@ -13,6 +15,16 @@ public interface BusinessEntityInfoMapper { ...@@ -13,6 +15,16 @@ public interface BusinessEntityInfoMapper {
// 插入业务实体信息 // 插入业务实体信息
@DataChangeLog(subjectType = SubjectType.BUSINESS_ENTITY_INFO, operation = OperationType.INSERT, idExpr = "id") @DataChangeLog(subjectType = SubjectType.BUSINESS_ENTITY_INFO, operation = OperationType.INSERT, idExpr = "id")
@DataFieldChangeLog(
subjectType = SubjectType.BUSINESS_ENTITY_INFO,
operation = OperationType.INSERT,
idExpr = "id",
fields = {
FieldCode.BE_NAME,
FieldCode.BE_PRINCIPAL_TEL,
FieldCode.BE_WORKER_NUMBER
}
)
void insertBusinessEntityInfo(BusinessEntityInfo businessEntityInfo); void insertBusinessEntityInfo(BusinessEntityInfo businessEntityInfo);
// 根据ID查询业务实体信息 // 根据ID查询业务实体信息
...@@ -20,6 +32,17 @@ public interface BusinessEntityInfoMapper { ...@@ -20,6 +32,17 @@ public interface BusinessEntityInfoMapper {
// 更新业务实体信息 // 更新业务实体信息
@DataChangeLog(subjectType = SubjectType.BUSINESS_ENTITY_INFO, operation = OperationType.UPDATE, idExpr = "id") @DataChangeLog(subjectType = SubjectType.BUSINESS_ENTITY_INFO, operation = OperationType.UPDATE, idExpr = "id")
@DataFieldChangeLog(
subjectType = SubjectType.BUSINESS_ENTITY_INFO,
operation = OperationType.UPDATE,
idExpr = "id",
preLoadMapper = "com.ruoyi.system.mapper.house.BusinessEntityInfoMapper.selectBusinessEntityInfoById",
fields = {
FieldCode.BE_NAME,
FieldCode.BE_PRINCIPAL_TEL,
FieldCode.BE_WORKER_NUMBER
}
)
void updateBusinessEntityInfo(BusinessEntityInfo businessEntityInfo); void updateBusinessEntityInfo(BusinessEntityInfo businessEntityInfo);
// 删除业务实体信息 // 删除业务实体信息
......
...@@ -3,6 +3,8 @@ package com.ruoyi.system.mapper.house; ...@@ -3,6 +3,8 @@ package com.ruoyi.system.mapper.house;
import com.ruoyi.system.domain.changerecord.annotation.DataChangeLog; import com.ruoyi.system.domain.changerecord.annotation.DataChangeLog;
import com.ruoyi.system.domain.changerecord.enums.OperationType; import com.ruoyi.system.domain.changerecord.enums.OperationType;
import com.ruoyi.system.domain.changerecord.enums.SubjectType; import com.ruoyi.system.domain.changerecord.enums.SubjectType;
import com.ruoyi.system.domain.fieldchangerecord.annotation.DataFieldChangeLog;
import com.ruoyi.system.domain.fieldchangerecord.enums.FieldCode;
import com.ruoyi.system.domain.house.BusinessEntitySell; import com.ruoyi.system.domain.house.BusinessEntitySell;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
...@@ -11,7 +13,14 @@ import java.util.List; ...@@ -11,7 +13,14 @@ import java.util.List;
public interface BusinessEntitySellMapper { public interface BusinessEntitySellMapper {
// 插入业务实体销售信息;以经营主体侧记录一条修改日志 // 插入业务实体销售信息;以经营主体侧记录一条修改日志
// 字段级:以经营主体为 subject,记录 BE_YEAR_SELL 的新增
@DataChangeLog(subjectType = SubjectType.BUSINESS_ENTITY_INFO, operation = OperationType.UPDATE, idExpr = "businessEntityInfoId") @DataChangeLog(subjectType = SubjectType.BUSINESS_ENTITY_INFO, operation = OperationType.UPDATE, idExpr = "businessEntityInfoId")
@DataFieldChangeLog(
subjectType = SubjectType.BUSINESS_ENTITY_INFO,
operation = OperationType.INSERT,
idExpr = "businessEntityInfoId",
fields = {FieldCode.BE_YEAR_SELL}
)
void insertBusinessEntitySell(BusinessEntitySell businessEntitySell); void insertBusinessEntitySell(BusinessEntitySell businessEntitySell);
// 根据ID查询业务实体销售信息 // 根据ID查询业务实体销售信息
...@@ -25,6 +34,13 @@ public interface BusinessEntitySellMapper { ...@@ -25,6 +34,13 @@ public interface BusinessEntitySellMapper {
preLoadMapper = "com.ruoyi.system.mapper.house.BusinessEntitySellMapper.selectBusinessEntitySellById", preLoadMapper = "com.ruoyi.system.mapper.house.BusinessEntitySellMapper.selectBusinessEntitySellById",
idExpr = "@preload[*].businessEntityInfoId" idExpr = "@preload[*].businessEntityInfoId"
) )
@DataFieldChangeLog(
subjectType = SubjectType.BUSINESS_ENTITY_INFO,
operation = OperationType.UPDATE,
idExpr = "@preload[*].businessEntityInfoId",
preLoadMapper = "com.ruoyi.system.mapper.house.BusinessEntitySellMapper.selectBusinessEntitySellById",
fields = {FieldCode.BE_YEAR_SELL}
)
void updateBusinessEntitySell(BusinessEntitySell businessEntitySell); void updateBusinessEntitySell(BusinessEntitySell businessEntitySell);
// 删除业务实体销售信息;改为逻辑删除后以经营主体侧记录一条修改日志 // 删除业务实体销售信息;改为逻辑删除后以经营主体侧记录一条修改日志
......
...@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.metadata.IPage; ...@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.system.domain.changerecord.annotation.DataChangeLog; import com.ruoyi.system.domain.changerecord.annotation.DataChangeLog;
import com.ruoyi.system.domain.changerecord.enums.OperationType; import com.ruoyi.system.domain.changerecord.enums.OperationType;
import com.ruoyi.system.domain.changerecord.enums.SubjectType; import com.ruoyi.system.domain.changerecord.enums.SubjectType;
import com.ruoyi.system.domain.fieldchangerecord.annotation.DataFieldChangeLog;
import com.ruoyi.system.domain.fieldchangerecord.enums.FieldCode;
import com.ruoyi.system.domain.house.HouseResource; import com.ruoyi.system.domain.house.HouseResource;
import com.ruoyi.system.domain.house.vo.*; import com.ruoyi.system.domain.house.vo.*;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
...@@ -13,17 +15,57 @@ import java.util.List; ...@@ -13,17 +15,57 @@ import java.util.List;
public interface HouseResourceMapper { public interface HouseResourceMapper {
@DataChangeLog(subjectType = SubjectType.HOUSE_RESOURCE, operation = OperationType.INSERT, idExpr = "id") @DataChangeLog(subjectType = SubjectType.HOUSE_RESOURCE, operation = OperationType.INSERT, idExpr = "id")
@DataFieldChangeLog(
subjectType = SubjectType.HOUSE_RESOURCE,
operation = OperationType.INSERT,
idExpr = "id",
fields = {
FieldCode.HR_BUSINESS_STATUS,
FieldCode.HR_AREA,
FieldCode.HR_EQUITY_TEL,
FieldCode.HR_UNIT_PRICE,
FieldCode.HR_IMAGE,
FieldCode.HR_LICENSE_IMAGE
}
)
void insertHouseResource(HouseResource houseResource); void insertHouseResource(HouseResource houseResource);
HouseResource selectHouseResourceById(String id); HouseResource selectHouseResourceById(String id);
@DataChangeLog(subjectType = SubjectType.HOUSE_RESOURCE, operation = OperationType.UPDATE, idExpr = "id") @DataChangeLog(subjectType = SubjectType.HOUSE_RESOURCE, operation = OperationType.UPDATE, idExpr = "id")
@DataFieldChangeLog(
subjectType = SubjectType.HOUSE_RESOURCE,
operation = OperationType.UPDATE,
idExpr = "id",
preLoadMapper = "com.ruoyi.system.mapper.house.HouseResourceMapper.selectHouseResourceById",
fields = {
FieldCode.HR_BUSINESS_STATUS,
FieldCode.HR_AREA,
FieldCode.HR_EQUITY_TEL,
FieldCode.HR_UNIT_PRICE,
FieldCode.HR_IMAGE,
FieldCode.HR_LICENSE_IMAGE
}
)
void updateHouseResource(HouseResource houseResource); void updateHouseResource(HouseResource houseResource);
@DataChangeLog(subjectType = SubjectType.HOUSE_RESOURCE, operation = OperationType.DELETE, idExpr = ".") @DataChangeLog(subjectType = SubjectType.HOUSE_RESOURCE, operation = OperationType.DELETE, idExpr = ".")
void deleteHouseResourceById(String id); void deleteHouseResourceById(String id);
@DataChangeLog(subjectType = SubjectType.HOUSE_RESOURCE, operation = OperationType.INSERT, idExpr = "list[*].id") @DataChangeLog(subjectType = SubjectType.HOUSE_RESOURCE, operation = OperationType.INSERT, idExpr = "list[*].id")
@DataFieldChangeLog(
subjectType = SubjectType.HOUSE_RESOURCE,
operation = OperationType.INSERT,
idExpr = "list[*].id",
fields = {
FieldCode.HR_BUSINESS_STATUS,
FieldCode.HR_AREA,
FieldCode.HR_EQUITY_TEL,
FieldCode.HR_UNIT_PRICE,
FieldCode.HR_IMAGE,
FieldCode.HR_LICENSE_IMAGE
}
)
void batchInsertHouseResources(List<HouseResource> houseResources); void batchInsertHouseResources(List<HouseResource> houseResources);
IPage<HouseResourcePage> selectPage(IPage<HouseResource> page, @Param("query") HouseResourcePageQuery houseResourcePageQuery); IPage<HouseResourcePage> selectPage(IPage<HouseResource> page, @Param("query") HouseResourcePageQuery houseResourcePageQuery);
......
package com.ruoyi.system.service.fieldchangerecord;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeRecordQuery;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeRecordVO;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeSummaryRow;
import java.util.List;
/**
* 字段级变更查询服务。
*
* <p>对外提供 HR / BE 两条独立的查询路径(明细 + 汇总)。写入由拦截器完成,
* 本服务不涉及写入。</p>
*/
public interface DataFieldChangeRecordService {
IPage<FieldChangeRecordVO> pageHouseResourceFieldChanges(FieldChangeRecordQuery query);
List<FieldChangeSummaryRow> summaryHouseResourceFieldChanges(FieldChangeRecordQuery query);
IPage<FieldChangeRecordVO> pageBusinessEntityFieldChanges(FieldChangeRecordQuery query);
List<FieldChangeSummaryRow> summaryBusinessEntityFieldChanges(FieldChangeRecordQuery query);
}
package com.ruoyi.system.service.fieldchangerecord.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.domain.changerecord.enums.SubjectType;
import com.ruoyi.system.domain.fieldchangerecord.enums.FieldCode;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeRecordQuery;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeRecordVO;
import com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeSummaryRow;
import com.ruoyi.system.domain.grid.GridRegionUser;
import com.ruoyi.system.domain.grid.GridRegionUserExample;
import com.ruoyi.system.mapper.fieldchangerecord.DataFieldChangeRecordQueryMapper;
import com.ruoyi.system.mapper.grid.GridRegionUserMapper;
import com.ruoyi.system.service.fieldchangerecord.DataFieldChangeRecordService;
import com.ruoyi.system.service.house.impl.HouseResourceServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 字段级变更查询服务实现。
*
* <p>职责:</p>
* <ul>
* <li>参数校验(时间范围必填、start &lt;= end);</li>
* <li>注入登录人网格权限 {@code wgCodes}(和 HR 侧 dataCollection 一致);</li>
* <li>当入参未指定 {@code fieldCodes} 时,补齐为当前主体侧的全部字段;</li>
* <li>调用 Mapper 查询;</li>
* <li>回填 {@code fieldLabel} / {@code valueType}(从 {@link FieldCode} 反查)。</li>
* </ul>
*
* <p>网格权限白名单与 HR 侧 {@code dataCollection} 完全一致。为避免循环依赖,
* 这里自己维护一份白名单 + 本地的 wgCodes 解析逻辑(和
* {@code HouseResourceServiceImpl.resolveCurrentUserWgCodes} 对齐)。</p>
*/
@Service
public class DataFieldChangeRecordServiceImpl implements DataFieldChangeRecordService {
private final DataFieldChangeRecordQueryMapper queryMapper;
private final GridRegionUserMapper gridRegionUserMapper;
public DataFieldChangeRecordServiceImpl(DataFieldChangeRecordQueryMapper queryMapper,
GridRegionUserMapper gridRegionUserMapper) {
this.queryMapper = queryMapper;
this.gridRegionUserMapper = gridRegionUserMapper;
}
@Override
public IPage<FieldChangeRecordVO> pageHouseResourceFieldChanges(FieldChangeRecordQuery query) {
validateTimeRange(query);
applyWgCodes(query);
applyDefaultFieldCodes(query, SubjectType.HOUSE_RESOURCE);
Page<FieldChangeRecordVO> page = new Page<>(
query.getPageNum() == null ? 1 : query.getPageNum(),
query.getPageSize() == null ? 20 : query.getPageSize());
IPage<FieldChangeRecordVO> result = queryMapper.selectHouseResourceFieldChanges(page, query);
enrichRecords(result.getRecords());
return result;
}
@Override
public List<FieldChangeSummaryRow> summaryHouseResourceFieldChanges(FieldChangeRecordQuery query) {
validateTimeRange(query);
applyWgCodes(query);
applyDefaultFieldCodes(query, SubjectType.HOUSE_RESOURCE);
List<FieldChangeSummaryRow> rows = queryMapper.selectHouseResourceFieldChangeSummary(query);
enrichSummary(rows);
return rows;
}
@Override
public IPage<FieldChangeRecordVO> pageBusinessEntityFieldChanges(FieldChangeRecordQuery query) {
validateTimeRange(query);
applyWgCodes(query);
applyDefaultFieldCodes(query, SubjectType.BUSINESS_ENTITY_INFO);
Page<FieldChangeRecordVO> page = new Page<>(
query.getPageNum() == null ? 1 : query.getPageNum(),
query.getPageSize() == null ? 20 : query.getPageSize());
IPage<FieldChangeRecordVO> result = queryMapper.selectBusinessEntityFieldChanges(page, query);
enrichRecords(result.getRecords());
return result;
}
@Override
public List<FieldChangeSummaryRow> summaryBusinessEntityFieldChanges(FieldChangeRecordQuery query) {
validateTimeRange(query);
applyWgCodes(query);
applyDefaultFieldCodes(query, SubjectType.BUSINESS_ENTITY_INFO);
List<FieldChangeSummaryRow> rows = queryMapper.selectBusinessEntityFieldChangeSummary(query);
enrichSummary(rows);
return rows;
}
// -------------------------------------------------------------------------
private void validateTimeRange(FieldChangeRecordQuery query) {
if (query.getChangeStartTime() == null || query.getChangeEndTime() == null) {
throw new IllegalArgumentException("changeStartTime / changeEndTime 必填");
}
if (query.getChangeStartTime().after(query.getChangeEndTime())) {
throw new IllegalArgumentException("changeStartTime 不能晚于 changeEndTime");
}
}
private void applyWgCodes(FieldChangeRecordQuery query) {
List<String> wgCodes = resolveCurrentUserWgCodes();
if (wgCodes != null) {
query.setWgCodes(wgCodes);
}
}
/**
* 与 {@code HouseResourceServiceImpl.resolveCurrentUserWgCodes(true)} 行为一致。
*/
private List<String> resolveCurrentUserWgCodes() {
String userId = SecurityUtils.getLoginUser().getUser().getUserId();
GridRegionUserExample example = new GridRegionUserExample();
example.createCriteria().andIsValidEqualTo("1").andUserIdEqualTo(userId);
List<GridRegionUser> gridRegionUsers = gridRegionUserMapper.selectByExample(example);
if (CollectionUtils.isEmpty(gridRegionUsers) || SecurityUtils.getLoginUser().getUser().isAdmin()) {
return null;
}
if (HouseResourceServiceImpl.WG_PERMISSION_BYPASS_USER_IDS.contains(userId)) {
return null;
}
return gridRegionUsers.stream().map(GridRegionUser::getWgId).collect(Collectors.toList());
}
/**
* 若入参没指定 fieldCodes,则按主体侧默认补齐全部字段。
* 若指定了 fieldCodes,按入参原样使用(后端不做属于哪个主体的校验——前端分 tab 自然不会传错)。
*/
private void applyDefaultFieldCodes(FieldChangeRecordQuery query, SubjectType subjectType) {
if (query.getFieldCodes() == null || query.getFieldCodes().isEmpty()) {
List<String> defaults = Arrays.stream(FieldCode.values())
.filter(f -> f.getSubjectType() == subjectType)
.map(Enum::name)
.collect(Collectors.toList());
query.setFieldCodes(defaults);
}
}
private void enrichRecords(List<FieldChangeRecordVO> list) {
if (list == null) {
return;
}
for (FieldChangeRecordVO vo : list) {
FieldCode fc = FieldCode.fromCode(vo.getFieldCode());
if (fc != null) {
vo.setFieldLabel(fc.getLabel());
vo.setValueType(fc.getValueType().name());
}
}
}
private void enrichSummary(List<FieldChangeSummaryRow> list) {
if (list == null) {
return;
}
for (FieldChangeSummaryRow row : list) {
FieldCode fc = FieldCode.fromCode(row.getFieldCode());
if (fc != null) {
row.setFieldLabel(fc.getLabel());
row.setValueType(fc.getValueType().name());
}
}
}
}
...@@ -707,7 +707,7 @@ public class HouseResourceServiceImpl implements HouseResourceService { ...@@ -707,7 +707,7 @@ public class HouseResourceServiceImpl implements HouseResourceService {
* 目前只给 {@link #pageHouseResources}、{@link #businessEntityStatistics}、{@link #houseResourceDataCollection} * 目前只给 {@link #pageHouseResources}、{@link #businessEntityStatistics}、{@link #houseResourceDataCollection}
* 这类"列表/汇总"场景启用;{@link #exportListHouseResources} 保持原有行为不放开。 * 这类"列表/汇总"场景启用;{@link #exportListHouseResources} 保持原有行为不放开。
*/ */
private static final Set<String> WG_PERMISSION_BYPASS_USER_IDS = new HashSet<>(Arrays.asList( public static final Set<String> WG_PERMISSION_BYPASS_USER_IDS = new HashSet<>(Arrays.asList(
"794aa2c8b5c24933a30591dd7dc439ed", "794aa2c8b5c24933a30591dd7dc439ed",
"ca1df7d1a3f347dc9e73e8283dd134a5", "ca1df7d1a3f347dc9e73e8283dd134a5",
"3cf3b0fa6c0945919504be4aa237f89e")); "3cf3b0fa6c0945919504be4aa237f89e"));
......
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.fieldchangerecord.DataFieldChangeRecordMapper">
<insert id="batchInsert" parameterType="java.util.List">
INSERT INTO data_field_change_record
(id, subject_type, subject_id, field_code, old_value, new_value, operation_type, operation_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.id}, #{item.subjectType}, #{item.subjectId}, #{item.fieldCode},
#{item.oldValue}, #{item.newValue}, #{item.operationType}, #{item.operationTime})
</foreach>
</insert>
</mapper>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.fieldchangerecord.DataFieldChangeRecordQueryMapper">
<resultMap id="FieldChangeRecordVO"
type="com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeRecordVO">
<id property="id" column="id"/>
<result property="subjectType" column="subject_type"/>
<result property="subjectId" column="subject_id"/>
<result property="subjectName" column="subject_name"/>
<result property="fieldCode" column="field_code"/>
<result property="oldValue" column="old_value"/>
<result property="newValue" column="new_value"/>
<result property="operationType" column="operation_type"/>
<result property="operationTime" column="operation_time"/>
<result property="hrType" column="hr_type"/>
<result property="two" column="two"/>
<result property="three" column="three"/>
<result property="four" column="four"/>
<result property="associatedHouseResourceId" column="associated_hr_id"/>
<result property="associatedHouseResourceName" column="associated_hr_name"/>
</resultMap>
<resultMap id="FieldChangeSummaryRow"
type="com.ruoyi.system.domain.fieldchangerecord.vo.FieldChangeSummaryRow">
<result property="fieldCode" column="field_code"/>
<result property="operationType" column="operation_type"/>
<result property="count" column="cnt"/>
</resultMap>
<!-- =====================================================================
HR 侧
===================================================================== -->
<!-- 明细(分页由 MyBatis-Plus PaginationInterceptor 自动补 LIMIT / COUNT)。 -->
<select id="selectHouseResourceFieldChanges" resultMap="FieldChangeRecordVO">
SELECT
r.id,
r.subject_type,
r.subject_id,
CONCAT(IFNULL(hr.address, ''), IFNULL(hr.house_number, '')) AS subject_name,
r.field_code,
r.old_value,
r.new_value,
r.operation_type,
r.operation_time,
hr.type AS hr_type,
hr.two,
hr.three,
hr.four,
hr.id AS associated_hr_id,
CONCAT(IFNULL(hr.address, ''), IFNULL(hr.house_number, '')) AS associated_hr_name
FROM data_field_change_record r
INNER JOIN house_resource hr ON hr.id = r.subject_id AND hr.delete_flag = 0
<include refid="hrSideWhere"/>
ORDER BY r.operation_time DESC, r.id DESC
</select>
<!-- 汇总 -->
<select id="selectHouseResourceFieldChangeSummary" resultMap="FieldChangeSummaryRow">
SELECT r.field_code, r.operation_type, COUNT(*) AS cnt
FROM data_field_change_record r
INNER JOIN house_resource hr ON hr.id = r.subject_id AND hr.delete_flag = 0
<include refid="hrSideWhere"/>
GROUP BY r.field_code, r.operation_type
</select>
<sql id="hrSideWhere">
<where>
r.subject_type = 'HOUSE_RESOURCE'
<if test="query.changeStartTime != null">
AND r.operation_time &gt;= #{query.changeStartTime}
</if>
<if test="query.changeEndTime != null">
AND r.operation_time &lt;= #{query.changeEndTime}
</if>
<if test="query.fieldCodes != null and query.fieldCodes.size() > 0">
AND r.field_code IN
<foreach collection="query.fieldCodes" item="fc" open="(" separator="," close=")">
#{fc}
</foreach>
</if>
<if test="query.operationTypes != null and query.operationTypes.size() > 0">
AND r.operation_type IN
<foreach collection="query.operationTypes" item="ot" open="(" separator="," close=")">
#{ot}
</foreach>
</if>
<if test="query.hrTypes != null and query.hrTypes.size() > 0">
AND hr.type IN
<foreach collection="query.hrTypes" item="t" open="(" separator="," close=")">
#{t}
</foreach>
</if>
<if test="query.two != null and query.two != ''">
AND hr.two = #{query.two}
</if>
<if test="query.three != null and query.three != ''">
AND hr.three = #{query.three}
</if>
<if test="query.four != null and query.four != ''">
AND hr.four = #{query.four}
</if>
<if test="query.wgCodes != null and query.wgCodes.size() > 0">
AND hr.two IN
<foreach collection="query.wgCodes" item="wg" open="(" separator="," close=")">
#{wg}
</foreach>
</if>
</where>
</sql>
<!-- =====================================================================
BE 侧
===================================================================== -->
<!--
明细:data_field_change_record -> bei -> mapping -> hr2
一条 BE 日志对应 N 个关联房源时会展成 N 行;
前端带 hrTypes/网格筛选时天然按命中收敛,不带时交给外层 DISTINCT 收敛。
-->
<select id="selectBusinessEntityFieldChanges" resultMap="FieldChangeRecordVO">
SELECT
r.id,
r.subject_type,
r.subject_id,
bei.name AS subject_name,
r.field_code,
r.old_value,
r.new_value,
r.operation_type,
r.operation_time,
hr2.type AS hr_type,
hr2.two,
hr2.three,
hr2.four,
hr2.id AS associated_hr_id,
CONCAT(IFNULL(hr2.address, ''), IFNULL(hr2.house_number, '')) AS associated_hr_name
FROM data_field_change_record r
INNER JOIN business_entity_info bei
ON bei.id = r.subject_id AND bei.delete_flag = 0
INNER JOIN house_resource_business_entity_info_mapping m
ON m.business_entity_info_id = bei.id AND m.delete_flag = 0
INNER JOIN house_resource hr2
ON hr2.id = m.house_resource_id AND hr2.delete_flag = 0
<include refid="beSideWhere"/>
ORDER BY r.operation_time DESC, r.id DESC, hr2.id ASC
</select>
<!--
汇总:避免 JOIN 膨胀,用 COUNT(DISTINCT r.id) 按日志去重。
语义:某字段在时间窗内被改了多少次(不区分影响了多少关联房源)。
-->
<select id="selectBusinessEntityFieldChangeSummary" resultMap="FieldChangeSummaryRow">
SELECT r.field_code, r.operation_type, COUNT(DISTINCT r.id) AS cnt
FROM data_field_change_record r
INNER JOIN business_entity_info bei
ON bei.id = r.subject_id AND bei.delete_flag = 0
INNER JOIN house_resource_business_entity_info_mapping m
ON m.business_entity_info_id = bei.id AND m.delete_flag = 0
INNER JOIN house_resource hr2
ON hr2.id = m.house_resource_id AND hr2.delete_flag = 0
<include refid="beSideWhere"/>
GROUP BY r.field_code, r.operation_type
</select>
<sql id="beSideWhere">
<where>
r.subject_type = 'BUSINESS_ENTITY_INFO'
<if test="query.changeStartTime != null">
AND r.operation_time &gt;= #{query.changeStartTime}
</if>
<if test="query.changeEndTime != null">
AND r.operation_time &lt;= #{query.changeEndTime}
</if>
<if test="query.fieldCodes != null and query.fieldCodes.size() > 0">
AND r.field_code IN
<foreach collection="query.fieldCodes" item="fc" open="(" separator="," close=")">
#{fc}
</foreach>
</if>
<if test="query.operationTypes != null and query.operationTypes.size() > 0">
AND r.operation_type IN
<foreach collection="query.operationTypes" item="ot" open="(" separator="," close=")">
#{ot}
</foreach>
</if>
<if test="query.hrTypes != null and query.hrTypes.size() > 0">
AND hr2.type IN
<foreach collection="query.hrTypes" item="t" open="(" separator="," close=")">
#{t}
</foreach>
</if>
<if test="query.two != null and query.two != ''">
AND hr2.two = #{query.two}
</if>
<if test="query.three != null and query.three != ''">
AND hr2.three = #{query.three}
</if>
<if test="query.four != null and query.four != ''">
AND hr2.four = #{query.four}
</if>
<if test="query.wgCodes != null and query.wgCodes.size() > 0">
AND hr2.two IN
<foreach collection="query.wgCodes" item="wg" open="(" separator="," close=")">
#{wg}
</foreach>
</if>
</where>
</sql>
</mapper>
-- =================================================================
-- 字段级变更记录表
--
-- 用于记录对房源 (house_resource) 和经营主体 (business_entity_info /
-- business_entity_sell) 的指定字段进行 新增 / 修改 的 diff 日志。
-- 仅覆盖 operation_type IN (1, 2);逻辑删除继续走 data_change_record。
--
-- field_code / subject_type 取值见:
-- com.ruoyi.system.domain.fieldchangerecord.enums.FieldCode
-- com.ruoyi.system.domain.changerecord.enums.SubjectType
-- =================================================================
CREATE TABLE IF NOT EXISTS `data_field_change_record` (
`id` VARCHAR(64) NOT NULL COMMENT '记录 id',
`subject_type` VARCHAR(64) NOT NULL COMMENT 'HOUSE_RESOURCE / BUSINESS_ENTITY_INFO',
`subject_id` VARCHAR(64) NOT NULL COMMENT '主体 id(BE_YEAR_SELL 存 business_entity_info_id)',
`field_code` VARCHAR(64) NOT NULL COMMENT '业务语义字段码,详见 FieldCode 枚举',
`old_value` TEXT DEFAULT NULL COMMENT 'insert 时为 NULL;图片类字段直接存真实 URL 串',
`new_value` TEXT DEFAULT NULL COMMENT 'BE_YEAR_SELL 格式为 year=2025;value=523.50',
`operation_type` TINYINT NOT NULL COMMENT '1 新增 2 修改',
`operation_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `idx_query` (`subject_type`, `field_code`, `operation_time`),
KEY `idx_subject` (`subject_type`, `subject_id`, `operation_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字段级变更记录表';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment