From 1a6545013438eefb1a55741b2cee8ff8f5c7cc5f Mon Sep 17 00:00:00 2001 From: dd3s_206 Date: Wed, 16 Sep 2026 21:02:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9C=8B=E6=9D=BF-=E6=96=87?= =?UTF-8?q?=E4=BD=93=E6=B4=BB=E5=8A=A8=EF=BC=9A=E6=96=B0=E5=A2=9E=E5=81=A5?= =?UTF-8?q?=E6=AD=A5=E8=B5=B0=E6=B4=BB=E5=8A=A8=E6=9D=A5=E6=BA=90=EF=BC=9B?= =?UTF-8?q?=EF=BC=88=E5=81=A5=E8=BA=AB=E8=B5=B0=E6=B4=BB=E5=8A=A8=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=BC=93=E5=AD=98=EF=BC=9A300=20=E7=A7=92=EF=BC=8C?= =?UTF-8?q?=E5=81=A5=E8=BA=AB=E8=B5=B0=E6=8A=A5=E5=90=8D=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E7=BC=93=E5=AD=98=EF=BC=9A60=20=E7=A7=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CulturalSportsBoardController.java | 7 +- .../service/CulturalSportsBoardService.java | 4 +- .../impl/CulturalSportsBoardServiceImpl.java | 138 +++++++++++++++++- 3 files changed, 138 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/v/nutz/databoard/controller/CulturalSportsBoardController.java b/src/main/java/io/v/nutz/databoard/controller/CulturalSportsBoardController.java index a5afdf6..40c9d69 100644 --- a/src/main/java/io/v/nutz/databoard/controller/CulturalSportsBoardController.java +++ b/src/main/java/io/v/nutz/databoard/controller/CulturalSportsBoardController.java @@ -37,7 +37,7 @@ public class CulturalSportsBoardController { } /** - * year 为四位年度,period 为 all/first/second,source 为 all/culture/sports/training/family, + * year 为四位年度,period 为 all/first/second,source 为 all/culture/sports/training/family/fitnessWalk, * unitId 为人员所属单位 ID(空表示全部);activityId 为 options 返回的活动键(空表示全部)。返回 Result:code=0 时 data 为聚合看板数据, * 参数不合法时 code 非零、msg 为提示;不返回个人姓名、工号或联系方式。 */ @@ -82,7 +82,7 @@ public class CulturalSportsBoardController { return ("请选择有效年度,不能查询未来年度"); } if (!Arrays.asList("all", "first", "second").contains(period) - || !Arrays.asList("all", "culture", "sports", "training", "family").contains(source)) { + || !Arrays.asList("all", "culture", "sports", "training", "family", "fitnessWalk").contains(source)) { return ("统计周期或活动来源不正确"); } if (unitId != null && unitId.length() > 64) { @@ -90,7 +90,8 @@ public class CulturalSportsBoardController { } // 活动键来自 options;限制长度及来源前缀,防止跨来源串用筛选值。 if (activityId != null && !activityId.isEmpty()) { - String prefix = "family".equals(source) ? "family:" : "training".equals(source) ? "training:" : "activity:"; + String prefix = "family".equals(source) ? "family:" : "training".equals(source) ? "training:" + : "fitnessWalk".equals(source) ? "fitnessWalk:" : "activity:"; if (activityId.length() > 100 || (!"all".equals(source) && !activityId.startsWith(prefix))) { return ("活动参数与来源不匹配,请重新选择活动"); } diff --git a/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java b/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java index 86d3d16..23c3349 100644 --- a/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java +++ b/src/main/java/io/v/nutz/databoard/service/CulturalSportsBoardService.java @@ -7,14 +7,14 @@ public interface CulturalSportsBoardService { /** * 返回筛选项:years 为有活动的年度及当前年度,units 为统计人员所属单位。 - * sources 为去除同源活动后的四种来源,activities 为含来源、年度、月份的活动名称选项,scopeLabel 说明参与率分母范围。 + * sources 为去除同源活动后的五种来源,activities 为含来源、年度、月份的活动名称选项,scopeLabel 说明参与率分母范围。 */ NutMap options(); /** * @param year 活动开始年度,取四位年份 * @param period all 全年、first 上半年、second 下半年 - * @param source all 全部、culture 校工会文化活动、sports 体育活动、training 品牌活动、family 亲子活动 + * @param source all 全部、culture 校工会文化活动、sports 体育活动、training 品牌活动、family 亲子活动、fitnessWalk 健身走活动 * @param unitId 人员所属单位 ID;空字符串表示全部单位,unknown 表示未归属单位 * @param activityId 带来源前缀的活动键,空表示全部活动;必须与 source 对应 * @return summary 核心指标,monthly 月度人次,semesters 半年对比,units 单位明细,unions 工会分组人数及参与率, diff --git a/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java b/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java index e6304f4..25f4d38 100644 --- a/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java +++ b/src/main/java/io/v/nutz/databoard/service/impl/CulturalSportsBoardServiceImpl.java @@ -1,10 +1,15 @@ package io.v.nutz.databoard.service.impl; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; import io.v.nutz.databoard.service.CulturalSportsBoardService; +import io.v.nutz.fitnessWalk.utils.WeAppCloudUtil; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.entity.Record; import org.nutz.dao.sql.Sql; +import org.nutz.integration.jedis.RedisService; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.util.NutMap; @@ -15,6 +20,7 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.math.BigInteger; import java.awt.Color; import java.awt.Font; @@ -22,9 +28,11 @@ import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.image.BufferedImage; import javax.imageio.ImageIO; +import java.time.Instant; import java.time.LocalDate; import java.time.Period; import java.time.YearMonth; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; @@ -37,6 +45,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; +import java.util.UUID; import java.util.stream.Collectors; /** 文体活动跨来源聚合;同源活动、同活动多项目报名和全年覆盖人数分别去重。 */ @@ -46,8 +55,22 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic @Inject private Dao dao; + @Inject + private WeAppCloudUtil weAppCloudUtil; + + @Inject + private RedisService redisService; + private static final String SCOPE_LABEL = "活动范围与有效报名人员合并去重"; private static final String UNKNOWN = "unknown"; + private static final String FITNESS_WALK_SOURCE = "fitnessWalk"; + private static final int CLOUD_PAGE_SIZE = 100; + private static final int CLOUD_AGGREGATE_PAGE_SIZE = 1000; + private static final int FITNESS_WALK_ACTIVITY_CACHE_SECONDS = 300; + private static final int FITNESS_WALK_PARTICIPANT_CACHE_SECONDS = 60; + private static final String FITNESS_WALK_ACTIVITY_CACHE_KEY = "DataBoard:FitnessWalk:Activities"; + private static final String FITNESS_WALK_PARTICIPANT_CACHE_PREFIX = "DataBoard:FitnessWalk:Participants:v2:"; + private static final ZoneId CHINA_ZONE = ZoneId.of("Asia/Shanghai"); /* 校级活动创建时会同步同 ID 到 activity_tissue;以 activity_school 为准,避免场次翻倍。 文化活动 state 为空是旧数据,无审核状态;保留此类历史活动,排除待审及驳回。 @@ -72,7 +95,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic /* 档案只补充归属和人员构成,不再用当前状态、禁用或删除标记抹去历史有效报名。 分母优先取活动名单,名单缺失时用当前在职人员兜底,再补入有效报名人员。 */ private static final String PEOPLE_SQL = """ - SELECT u.id, u.userState user_state, u.unitid, unit.name unit_name, unit.unionid unionid, un.unionname union_name, u.birthday, u.sex, u.personType person_type, u.jobTitle job_title + SELECT u.id, u.loginname, u.userState user_state, u.unitid, unit.name unit_name, unit.unionid unionid, un.unionname union_name, u.birthday, u.sex, u.personType person_type, u.jobTitle job_title FROM sys_user u LEFT JOIN sys_unit unit ON unit.id=u.unitid LEFT JOIN sys_union un ON un.id=unit.unionid """; @@ -91,7 +114,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic int currentYear = LocalDate.now().getYear(); years.add(currentYear); List activities = new ArrayList<>(); - for (NutMap event : query(EVENTS_SQL)) { + for (NutMap event : events("all")) { LocalDate date = parseDate(event.getString("starts_at")); if (date != null && date.getYear() >= 1900 && date.getYear() <= currentYear) { years.add(date.getYear()); @@ -105,7 +128,8 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic return new NutMap().setv("years", new ArrayList<>(years)).setv("units", unitOptions) .setv("activities", activities) .setv("sources", Arrays.asList(row("training", "品牌活动"), row("family", "亲子活动"), - row("sports", "体育活动"), row("culture", "校工会文化活动"))) + row("sports", "体育活动"), row("culture", "校工会文化活动"), + row(FITNESS_WALK_SOURCE, "健身走活动"))) .setv("currentYear", currentYear).setv("scopeLabel", SCOPE_LABEL); } @@ -117,13 +141,17 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic LocalDate cutoff = today.isBefore(yearEnd) ? today : yearEnd; String selectedUnit = text(unitId); String selectedActivity = text(activityId); + String effectiveSource = selectedActivity.startsWith("fitnessWalk:") ? FITNESS_WALK_SOURCE : source; Map people = new LinkedHashMap<>(); + Map personIdsByLoginName = new HashMap<>(); for (NutMap person : query(PEOPLE_SQL)) { people.put(person.getString("id"), person); + String loginName = text(person.getString("loginname")); + if (!loginName.isEmpty()) personIdsByLoginName.put(loginName.toLowerCase(java.util.Locale.ROOT), person.getString("id")); } Map events = new LinkedHashMap<>(); - for (NutMap event : query(EVENTS_SQL)) { + for (NutMap event : events(effectiveSource)) { LocalDate date = parseDate(event.getString("starts_at")); // 无有效开始时间或尚未开始的活动不属于“已开展”,不会归到报名月份冒充发生月份。 if (date == null || date.isBefore(yearStart) || date.isAfter(cutoff) @@ -158,10 +186,18 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic Sql sql = Sqls.create(participationSql); sql.setParam("startDate", yearStart.toString()); sql.setParam("endDate", cutoff.plusDays(1).toString()); - for (NutMap participant : query(sql)) { + List participations = FITNESS_WALK_SOURCE.equals(effectiveSource) ? new ArrayList<>() : query(sql); + participations.addAll(fitnessWalkParticipants(events)); + for (NutMap participant : participations) { String eventKey = participant.getString("event_key"); NutMap event = events.get(eventKey); String personId = text(participant.getString("user_id")); + if (personId.isEmpty() || !people.containsKey(personId)) { + String loginName = text(participant.getString("loginname")); + String matchedId = loginName.isEmpty() ? "" + : text(personIdsByLoginName.get(loginName.toLowerCase(java.util.Locale.ROOT))); + if (!matchedId.isEmpty()) personId = matchedId; + } if (event == null) continue; // 缺少人员 ID 无法跨报名去重,单独报告;有 ID 但无档案仍保留并归入未知。 if (personId.isEmpty()) { @@ -204,7 +240,7 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic } } - // 只查询所选周期内活动引用的人员名单,四类活动均按实际范围 ID 关联。 + // 只查询所选周期内活动引用的人员名单,各类活动均按实际范围 ID 关联。 List selectedEvents = events.values().stream() .filter(event -> inPeriod(event.getInt("month"), period)).collect(Collectors.toList()); List scopeIds = selectedEvents.stream().map(event -> text(event.getString("scope_id"))) @@ -488,10 +524,100 @@ public class CulturalSportsBoardServiceImpl implements CulturalSportsBoardServic case "family": return "亲子活动"; case "sports": return "体育活动"; case "culture": return "校工会文化活动"; + case FITNESS_WALK_SOURCE: return "健身走活动"; default: return "全部来源"; } } + /** 按来源加载活动,单一来源查询时跳过无关数据源。 */ + private List events(String source) { + List result = FITNESS_WALK_SOURCE.equals(source) ? new ArrayList<>() : query(EVENTS_SQL); + if ("all".equals(source) || FITNESS_WALK_SOURCE.equals(source)) result.addAll(fitnessWalkEvents()); + return result; + } + + /** 健身走活动短时缓存供筛选项和紧随其后的统计请求复用;兼容新旧名称和时间字段。 */ + private List fitnessWalkEvents() { + List activities; + if (redisService.exists(FITNESS_WALK_ACTIVITY_CACHE_KEY)) { + activities = JSON.parseArray(redisService.get(FITNESS_WALK_ACTIVITY_CACHE_KEY), JSONObject.class); + } else { + activities = new ArrayList<>(); + int offset = 0; + while (true) { + String cloudSql = "db.collection('activity').field({_id:true,masterActivityName:true,branchActivityName:true," + + "tag:true,name:true,startTime:true,start_time:true,groupId:true}).orderBy('_id','asc').skip(" + offset + ").limit(" + + CLOUD_PAGE_SIZE + ").get()"; + JSONArray data = weAppCloudUtil.request(WeAppCloudUtil.CRUD.QUERY, cloudSql).getJSONArray("data"); + if (data == null || data.isEmpty()) break; + for (Object value : data) activities.add(JSON.parseObject((String) value)); + if (data.size() < CLOUD_PAGE_SIZE) break; + offset += CLOUD_PAGE_SIZE; + } + redisService.setex(FITNESS_WALK_ACTIVITY_CACHE_KEY, FITNESS_WALK_ACTIVITY_CACHE_SECONDS, JSON.toJSONString(activities)); + } + List result = new ArrayList<>(); + for (JSONObject activity : activities) { + String id = text(activity.getString("_id")); + Long startTime = activity.getLong("startTime"); + if (startTime == null) startTime = activity.getLong("start_time"); + if (id.isEmpty() || startTime == null) continue; + String masterName = firstText(activity, "masterActivityName", "tag"); + String branchName = firstText(activity, "branchActivityName", "name"); + String activityName = masterName.isEmpty() ? branchName : branchName.isEmpty() || masterName.equals(branchName) + ? masterName : masterName + "(" + branchName + ")"; + if (activityName.isEmpty()) activityName = "未命名活动"; + LocalDate startDate = Instant.ofEpochMilli(startTime).atZone(CHINA_ZONE).toLocalDate(); + result.add(new NutMap().setv("event_key", "fitnessWalk:" + id).setv("id", id) + .setv("source", FITNESS_WALK_SOURCE).setv("starts_at", startDate.toString()) + .setv("name", activityName).setv("scope_id", text(activity.getString("groupId")))); + } + return result; + } + + /** 健身走报名先在云端按活动和人员去重,再分页返回;短时缓存供周期、单位筛选复用。 */ + private List fitnessWalkParticipants(Map events) { + List activityIds = events.values().stream() + .filter(event -> FITNESS_WALK_SOURCE.equals(event.getString("source"))) + .map(event -> event.getString("id")).distinct().sorted().collect(Collectors.toList()); + if (activityIds.isEmpty()) return new ArrayList<>(); + String cacheId = UUID.nameUUIDFromBytes(String.join(",", activityIds).getBytes(StandardCharsets.UTF_8)).toString(); + String cacheKey = FITNESS_WALK_PARTICIPANT_CACHE_PREFIX + cacheId; + List registers; + if (redisService.exists(cacheKey)) { + registers = JSON.parseArray(redisService.get(cacheKey), JSONObject.class); + } else { + registers = new ArrayList<>(); + int offset = 0; + while (true) { + String cloudSql = "db.collection('register').aggregate().match({activity_id:_.in(" + + JSON.toJSONString(activityIds) + ")}).group({_id:{activity_id:'$activity_id',userid:'$userid'," + + "loginname:'$loginname'}}).project({_id:0,activity_id:'$_id.activity_id',userid:'$_id.userid'," + + "loginname:'$_id.loginname'}).sort({activity_id:1,userid:1,loginname:1}).skip(" + offset + + ").limit(" + CLOUD_AGGREGATE_PAGE_SIZE + ").end()"; + JSONArray data = weAppCloudUtil.request(WeAppCloudUtil.CRUD.AGGREGATE, cloudSql).getJSONArray("data"); + if (data == null || data.isEmpty()) break; + for (Object value : data) registers.add(JSON.parseObject((String) value)); + if (data.size() < CLOUD_AGGREGATE_PAGE_SIZE) break; + offset += CLOUD_AGGREGATE_PAGE_SIZE; + } + redisService.setex(cacheKey, FITNESS_WALK_PARTICIPANT_CACHE_SECONDS, JSON.toJSONString(registers)); + } + List result = new ArrayList<>(); + for (JSONObject register : registers) { + String activityId = text(register.getString("activity_id")); + result.add(new NutMap().setv("event_key", "fitnessWalk:" + activityId) + .setv("user_id", text(register.getString("userid"))) + .setv("loginname", text(register.getString("loginname")))); + } + return result; + } + + private String firstText(JSONObject object, String firstField, String secondField) { + String value = text(object.getString(firstField)); + return value.isEmpty() ? text(object.getString(secondField)) : value; + } + /** 缺失分母不解释为零;百分比使用与后端相同的两位小数精度。 */ private String reportPercent(Object value) { return value == null ? "—" : String.format(java.util.Locale.ROOT, "%.2f%%", ((Number) value).doubleValue());