数据看板-文体活动:新增健步走活动来源;(健身走活动列表缓存:300 秒,健身走报名统计缓存:60 秒)
This commit is contained in:
@@ -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 ("活动参数与来源不匹配,请重新选择活动");
|
||||
}
|
||||
|
||||
@@ -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 工会分组人数及参与率,
|
||||
|
||||
+132
-6
@@ -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<NutMap> 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<String, NutMap> people = new LinkedHashMap<>();
|
||||
Map<String, String> 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<String, NutMap> 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<NutMap> 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<NutMap> selectedEvents = events.values().stream()
|
||||
.filter(event -> inPeriod(event.getInt("month"), period)).collect(Collectors.toList());
|
||||
List<String> 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<NutMap> events(String source) {
|
||||
List<NutMap> 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<NutMap> fitnessWalkEvents() {
|
||||
List<JSONObject> 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<NutMap> 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<NutMap> fitnessWalkParticipants(Map<String, NutMap> events) {
|
||||
List<String> 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<JSONObject> 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<NutMap> 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());
|
||||
|
||||
Reference in New Issue
Block a user