feat:同步 hrm、fms 最新进度

This commit is contained in:
YunaiV
2026-08-14 22:52:31 +08:00
parent b5a80f7e28
commit ae49ccd6d7
36 changed files with 6666 additions and 3689 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+32
View File
@@ -0,0 +1,32 @@
# LiveKit Server PoC
最小可用的 LiveKit Server 自部署验证环境,用于零期 PoC。
## 启动
```bash
cd tools/livekit-poc
docker compose up -d
bash verify.sh
```
## 端口
- 7880:HTTP / WebSocket 信令;
- 7881:WebRTC TCP fallback;
- 7882/UDP:WebRTC 媒体;
- macOS / Windows:当前 `docker-compose.yml` 走端口映射模式,`rtc.node_ip` 使用 `127.0.0.1` 让浏览器访问映射后的媒体端口,webhook URL 用 `host.docker.internal:48080` 让容器访问到宿主机 yudao 后端;
- macOS 上 host network(`network_mode: host`)需要 Docker Desktop 4.34+ 并在 Settings → Resources → Network 勾选「Enable host networking」,老版本静默失败(容器跑得起来但端口完全不通);
- Linux:可以把 `docker-compose.yml` 改成 `network_mode: host` + 删 `ports:` 段,并把 `livekit.yaml` 的 webhook URL 改为 `http://127.0.0.1:48080/admin-api/im/livekit/webhook`。
## 凭据 (仅 PoC,勿用于生产)
- `LIVEKIT_KEYS=devkey: secret-poc-key-min-32-chars-required-here`
- API Key:`devkey`
- API Secret:`secret-poc-key-min-32-chars-required-here`
生产环境必须改用强随机 secret,并通过 `--config /etc/livekit.yaml` 加载。
## 浏览器联调
`verify.sh` 跑完会输出一个 `meet.livekit.io` 链接,用两个浏览器(或两台机器)打开同一链接即可看到对方画面。
+20
View File
@@ -0,0 +1,20 @@
services:
livekit:
image: docker.m.daocloud.io/livekit/livekit-server:latest
container_name: yudao-livekit-dev
restart: unless-stopped
# 端口映射模式
# macOS / Windows 必走这种方式:Docker Desktop 4.34 以下没有 host network
# Linux 可以改 network_mode: host 省去映射,并把 livekit.yaml 的 webhook url 换成 127.0.0.1
ports:
- "7880:7880" # HTTP / WebSocket 信令
- "7881:7881" # WebRTC TCP fallback
- "7882:7882/udp" # WebRTC UDP (dev 模式 UDP mux 单端口)
volumes:
# 挂载 config 文件;webhook 配置在 livekit.yaml 里
- ./livekit.yaml:/etc/livekit.yaml:ro
command:
- --config
- /etc/livekit.yaml
- --bind
- 0.0.0.0
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# LiveKit Server PoC 验证脚本;
# 用法: bash verify.sh
set -e
API_KEY="${LIVEKIT_API_KEY:-devkey}"
API_SECRET="${LIVEKIT_API_SECRET:-secret-poc-key-min-32-chars-required-here}"
HOST="${LIVEKIT_HOST:-localhost:7880}"
ROOM="${LIVEKIT_ROOM:-poc-room}"
ok() { printf "[OK] %s\n" "$1"; }
fail() { printf "[FAIL] %s\n" "$1"; exit 1; }
echo "==> 1/5 等待 HTTP 端点就绪 (http://${HOST}/)"
for i in $(seq 1 20); do
code=$(curl -s -o /dev/null -w "%{http_code}" "http://${HOST}/" || echo "000")
[ "$code" = "200" ] && { ok "HTTP 200"; break; }
[ $i -eq 20 ] && fail "20 秒内未就绪 (last code=${code})"
sleep 1
done
echo "==> 2/5 签发管理 + 客户端权限 Token"
TOKEN=$(API_KEY="$API_KEY" API_SECRET="$API_SECRET" ROOM="$ROOM" python3 - <<'PY'
import json, time, hmac, hashlib, base64, os
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
header = b64u(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',',':')).encode())
payload = b64u(json.dumps({
"iss": os.environ["API_KEY"],
"sub": "poc-tester",
"name": "PoC Tester",
"video": {
"roomJoin": True, "room": os.environ["ROOM"],
"canPublish": True, "canSubscribe": True, "canPublishData": True,
"roomCreate": True, "roomList": True, "roomAdmin": True
},
"exp": int(time.time()) + 3600,
"nbf": int(time.time())
}, separators=(',',':')).encode())
sig = b64u(hmac.new(os.environ["API_SECRET"].encode(),
f"{header}.{payload}".encode(),
hashlib.sha256).digest())
print(f"{header}.{payload}.{sig}")
PY
)
[ -n "$TOKEN" ] || fail "Token 生成失败"
ok "Token 已生成 (${#TOKEN} chars)"
echo "==> 3/5 创建房间 ${ROOM} (CreateRoom RPC)"
create_resp=$(curl -s -X POST "http://${HOST}/twirp/livekit.RoomService/CreateRoom" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"name\":\"${ROOM}\",\"empty_timeout\":300,\"max_participants\":10}")
echo " 响应: $create_resp"
echo "$create_resp" | jq -e '.sid' >/dev/null 2>&1 \
&& ok "房间已创建" \
|| fail "CreateRoom 失败"
echo "==> 4/5 列出房间 (ListRooms RPC)"
list_resp=$(curl -s -X POST "http://${HOST}/twirp/livekit.RoomService/ListRooms" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{}')
room_count=$(echo "$list_resp" | jq '.rooms | length' 2>/dev/null || echo "0")
ok "当前房间数: ${room_count}"
echo "$list_resp" | jq '.'
echo "==> 5/5 删除房间 (DeleteRoom RPC) —— 清理"
del_resp=$(curl -s -X POST "http://${HOST}/twirp/livekit.RoomService/DeleteRoom" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"room\":\"${ROOM}\"}")
ok "删除响应: $del_resp"
# 重新签一个仅 client 权限的 token;用于浏览器进会
CLIENT_TOKEN=$(API_KEY="$API_KEY" API_SECRET="$API_SECRET" ROOM="$ROOM" python3 - <<'PY'
import json, time, hmac, hashlib, base64, os
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
header = b64u(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',',':')).encode())
payload = b64u(json.dumps({
"iss": os.environ["API_KEY"],
"sub": "browser-tester",
"name": "Browser",
"video": {
"roomJoin": True, "room": os.environ["ROOM"],
"canPublish": True, "canSubscribe": True, "canPublishData": True
},
"exp": int(time.time()) + 7200
}, separators=(',',':')).encode())
sig = b64u(hmac.new(os.environ["API_SECRET"].encode(),
f"{header}.{payload}".encode(),
hashlib.sha256).digest())
print(f"{header}.{payload}.{sig}")
PY
)
echo ""
echo "============================================================"
echo " LiveKit Server 验证通过"
echo "============================================================"
echo " 浏览器测试 (开两个窗口能互通):"
echo " https://meet.livekit.io/?liveKitUrl=ws%3A%2F%2F${HOST}&token=${CLIENT_TOKEN}"
echo ""
echo " 停止服务:"
echo " docker compose -f tools/livekit-poc/docker-compose.yml down"
echo "============================================================"
+5273 -3637
View File
File diff suppressed because it is too large Load Diff
@@ -46,9 +46,7 @@ public class CollectionUtils {
if (CollUtil.isEmpty(from)) {
return BigDecimal.ZERO;
}
return from.stream().map(valueFunc)
.map(value -> value == null ? BigDecimal.ZERO : value)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return from.stream().map(valueFunc).map(NumberUtils::zeroIfNull).reduce(BigDecimal.ZERO, BigDecimal::add);
}
public static <T> long count(Collection<T> from, Predicate<T> predicate) {
@@ -6,7 +6,9 @@ import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.TreeSet;
import java.util.function.BiFunction;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -26,6 +28,38 @@ public class CollectionUtilsTest {
}
@Test
public void testSum() {
assertEquals(6L, CollectionUtils.sum(Arrays.asList(1L, 2L, 3L), Long::longValue));
assertEquals(0L, CollectionUtils.sum(Collections.<Long>emptyList(), Long::longValue));
}
@Test
public void testCount() {
assertEquals(2L, CollectionUtils.count(Arrays.asList(1, 2, 3, 4), value -> value % 2 == 0));
assertEquals(0L, CollectionUtils.count(Collections.emptyList(), value -> true));
}
@Test
public void testDistinctCount() {
assertEquals(3L, CollectionUtils.distinctCount(Arrays.asList("a", "b", "a", "c"), value -> value));
assertEquals(0L, CollectionUtils.distinctCount(Collections.emptyList(), value -> value));
}
@Test
public void testConvertSet_withSupplier() {
// 调用,并断言:按指定 Set 类型完成转换、去重和排序
TreeSet<Integer> result = CollectionUtils.convertSetBySupplier(
Arrays.asList("3", "1", null, "3", "2"),
value -> value == null ? null : Integer.valueOf(value), TreeSet::new);
assertEquals(Arrays.asList(1, 2, 3), Arrays.asList(result.toArray(new Integer[0])));
// 调用,并断言:空集合仍返回指定 Set 类型
result = CollectionUtils.convertSetBySupplier(Collections.<String>emptyList(),
Integer::valueOf, TreeSet::new);
assertEquals(TreeSet.class, result.getClass());
}
@Test
public void testDiffList() {
// 准备参数
@@ -0,0 +1,342 @@
package cn.iocoder.yudao.framework.common.util.date;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.TimeRange;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.YearMonth;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@link LocalDateTimeUtils} 的单元测试
*
* @author 芋道源码
*/
public class LocalDateTimeUtilsTest {
@Test
public void testDateCompare() {
// 准备参数
LocalDate firstDate = LocalDate.of(2026, 7, 23);
LocalDate secondDate = LocalDate.of(2026, 7, 24);
// 调用,并断言
assertTrue(LocalDateTimeUtils.isBeforeOrEqual(firstDate, secondDate));
assertTrue(LocalDateTimeUtils.isBeforeOrEqual(secondDate, secondDate));
assertFalse(LocalDateTimeUtils.isBeforeOrEqual(secondDate, firstDate));
assertTrue(LocalDateTimeUtils.isAfterOrEqual(secondDate, firstDate));
assertTrue(LocalDateTimeUtils.isAfterOrEqual(secondDate, secondDate));
assertFalse(LocalDateTimeUtils.isAfterOrEqual(firstDate, secondDate));
}
@Test
public void testDateTimeCompare() {
// 准备参数
LocalDateTime firstTime = LocalDateTime.of(2026, 7, 24, 9, 0);
LocalDateTime secondTime = LocalDateTime.of(2026, 7, 24, 18, 0);
// 调用,并断言
assertTrue(LocalDateTimeUtils.isBeforeOrEqual(firstTime, secondTime));
assertTrue(LocalDateTimeUtils.isBeforeOrEqual(secondTime, secondTime));
assertFalse(LocalDateTimeUtils.isBeforeOrEqual(secondTime, firstTime));
assertTrue(LocalDateTimeUtils.isAfterOrEqual(secondTime, firstTime));
assertTrue(LocalDateTimeUtils.isAfterOrEqual(secondTime, secondTime));
assertFalse(LocalDateTimeUtils.isAfterOrEqual(firstTime, secondTime));
}
@Test
public void testIsTimeRangeValid() {
// 准备参数
LocalDateTime beginTime = LocalDateTime.of(2026, 7, 24, 9, 0);
LocalDateTime endTime = LocalDateTime.of(2026, 7, 24, 18, 0);
// 调用,并断言
assertTrue(LocalDateTimeUtils.isTimeRangeValid(beginTime, endTime));
assertFalse(LocalDateTimeUtils.isTimeRangeValid(beginTime, beginTime));
assertFalse(LocalDateTimeUtils.isTimeRangeValid(endTime, beginTime));
assertFalse(LocalDateTimeUtils.isTimeRangeValid(null, endTime));
assertFalse(LocalDateTimeUtils.isTimeRangeValid(beginTime, null));
}
@Test
public void testIsTimeRangePresent() {
// 准备参数
LocalDateTime time = LocalDateTime.of(2026, 7, 24, 9, 0);
// 调用,并断言:空数组和双空边界都不是有效范围
assertFalse(LocalDateTimeUtils.isTimeRangePresent(null));
assertFalse(LocalDateTimeUtils.isTimeRangePresent(new LocalDateTime[0]));
assertFalse(LocalDateTimeUtils.isTimeRangePresent(new LocalDateTime[]{null, null}));
// 调用,并断言:单边或双边存在即为有效范围
assertTrue(LocalDateTimeUtils.isTimeRangePresent(new LocalDateTime[]{time}));
assertTrue(LocalDateTimeUtils.isTimeRangePresent(new LocalDateTime[]{null, time}));
assertTrue(LocalDateTimeUtils.isTimeRangePresent(new LocalDateTime[]{time, time.plusHours(1)}));
}
@Test
public void testGetDayTime() {
// 准备参数
LocalDate date = LocalDate.of(2026, 7, 24);
LocalDateTime time = date.atTime(15, 30);
// 调用,并断言
assertEquals(LocalDateTime.of(2026, 7, 24, 0, 0),
LocalDateTimeUtils.getDayBeginTime(date));
assertEquals(LocalDateTime.of(2026, 7, 24, 0, 0),
LocalDateTimeUtils.getDayBeginTime(time));
assertNull(LocalDateTimeUtils.getDayBeginTime((LocalDateTime) null));
assertEquals(LocalDateTime.of(date, LocalTime.MAX),
LocalDateTimeUtils.getDayEndTime(date));
}
@Test
public void testGetDateTimeRange() {
// 准备参数
LocalDate beginDate = LocalDate.of(2026, 7, 1);
LocalDate endDate = LocalDate.of(2026, 7, 31);
// 调用,并断言:左右两端均为闭区间边界
LocalDateTime[] result = LocalDateTimeUtils.getDateTimeRange(beginDate, endDate);
assertEquals(beginDate.atStartOfDay(), result[0]);
assertEquals(endDate.atTime(LocalTime.MAX), result[1]);
// 调用,并断言:支持单边范围
result = LocalDateTimeUtils.getDateTimeRange(beginDate, null);
assertEquals(beginDate.atStartOfDay(), result[0]);
assertNull(result[1]);
result = LocalDateTimeUtils.getDateTimeRange(null, endDate);
assertNull(result[0]);
assertEquals(endDate.atTime(LocalTime.MAX), result[1]);
// 调用,并断言:两个边界都为空时不生成范围
assertNull(LocalDateTimeUtils.getDateTimeRange(null, null));
}
@Test
public void testGetMonthDateTimeRange() {
// 调用:闰年二月
LocalDateTime[] result = LocalDateTimeUtils.getMonthDateTimeRange(2024, 2);
// 断言:完整包含闰年二月的首尾时刻
assertEquals(LocalDateTime.of(2024, 2, 1, 0, 0), result[0]);
assertEquals(LocalDate.of(2024, 2, 29).atTime(LocalTime.MAX), result[1]);
}
@Test
public void testGetMonthBeginTime() {
// 准备参数
YearMonth month = YearMonth.of(2026, 12);
// 调用,并断言
assertEquals(LocalDateTime.of(2026, 12, 1, 0, 0),
LocalDateTimeUtils.getMonthBeginTime(month));
assertEquals(LocalDateTime.of(2027, 1, 1, 0, 0),
LocalDateTimeUtils.getNextMonthBeginTime(month));
}
@Test
public void testIsBetween() {
// 准备参数
LocalDate beginDate = LocalDate.of(2026, 7, 23);
LocalDate endDate = LocalDate.of(2026, 7, 25);
LocalDateTime beginTime = beginDate.atTime(9, 0);
LocalDateTime endTime = beginDate.atTime(18, 0);
// 调用,并断言:日期闭区间
assertTrue(LocalDateTimeUtils.isBetween(beginDate, endDate, beginDate));
assertTrue(LocalDateTimeUtils.isBetween(beginDate, endDate, LocalDate.of(2026, 7, 24)));
assertTrue(LocalDateTimeUtils.isBetween(beginDate, endDate, endDate));
assertFalse(LocalDateTimeUtils.isBetween(beginDate, endDate, LocalDate.of(2026, 7, 26)));
assertFalse(LocalDateTimeUtils.isBetween(beginDate, endDate, null));
assertFalse(LocalDateTimeUtils.isNotBetween(beginDate, endDate, beginDate));
assertTrue(LocalDateTimeUtils.isNotBetween(beginDate, endDate, LocalDate.of(2026, 7, 26)));
assertTrue(LocalDateTimeUtils.isNotBetween(beginDate, endDate, null));
// 调用,并断言:时间闭区间
assertTrue(LocalDateTimeUtils.isBetween(beginTime, endTime, beginTime));
assertTrue(LocalDateTimeUtils.isBetween(beginTime, endTime, beginDate.atTime(12, 0)));
assertTrue(LocalDateTimeUtils.isBetween(beginTime, endTime, endTime));
assertFalse(LocalDateTimeUtils.isBetween(beginTime, endTime, beginDate.atTime(18, 1)));
assertFalse(LocalDateTimeUtils.isBetween(beginTime, endTime, (LocalDateTime) null));
assertFalse(LocalDateTimeUtils.isNotBetween(beginTime, endTime, endTime));
assertTrue(LocalDateTimeUtils.isNotBetween(beginTime, endTime, beginDate.atTime(18, 1)));
assertTrue(LocalDateTimeUtils.isNotBetween(beginTime, endTime, null));
}
@Test
public void testIsClosedRangeOverlap() {
// 准备参数
LocalDateTime firstBeginTime = LocalDateTime.of(2026, 7, 24, 9, 0);
LocalDateTime firstEndTime = LocalDateTime.of(2026, 7, 24, 12, 0);
// 调用,并断言:首尾相接属于闭区间重叠
assertTrue(LocalDateTimeUtils.isClosedRangeOverlap(
firstBeginTime, firstEndTime, firstEndTime, firstEndTime.plusHours(1)));
assertTrue(LocalDateTimeUtils.isClosedRangeOverlap(
firstBeginTime, firstEndTime, firstBeginTime.minusHours(1), firstBeginTime));
assertFalse(LocalDateTimeUtils.isClosedRangeOverlap(
firstBeginTime, firstEndTime, firstEndTime.plusNanos(1), firstEndTime.plusHours(1)));
assertFalse(LocalDateTimeUtils.isClosedRangeOverlap(
firstBeginTime, firstEndTime, null, firstEndTime));
}
@Test
public void testSubtractTimeRanges() {
// 准备参数
LocalDate date = LocalDate.of(2026, 7, 24);
TimeRange sourceRange = new TimeRange(date.atTime(9, 0), date.atTime(18, 0));
// 调用:扣除原时段中间的一段
List<TimeRange> result = LocalDateTimeUtils.subtractTimeRanges(
Collections.singletonList(sourceRange),
new TimeRange(date.atTime(12, 0), date.atTime(13, 0)));
// 断言:原时段被拆分为两段
assertEquals(2, result.size());
assertTimeRange(CollUtil.getFirst(result), date.atTime(9, 0), date.atTime(12, 0));
assertTimeRange(result.get(1), date.atTime(13, 0), date.atTime(18, 0));
// 调用,并断言:扣除时段完整覆盖原时段
result = LocalDateTimeUtils.subtractTimeRanges(
Collections.singletonList(sourceRange),
new TimeRange(date.atTime(8, 0), date.atTime(19, 0)));
assertTrue(result.isEmpty());
// 调用:扣除时段与原时段首尾相接
result = LocalDateTimeUtils.subtractTimeRanges(
Collections.singletonList(sourceRange),
new TimeRange(date.atTime(18, 0), date.atTime(19, 0)));
// 断言:两个时段仅首尾相接,没有正时长交集,原时段保持不变
assertEquals(1, result.size());
assertTimeRange(CollUtil.getFirst(result), date.atTime(9, 0), date.atTime(18, 0));
}
@Test
public void testMergeTimeRanges() {
// 准备参数
LocalDate date = LocalDate.of(2026, 7, 24);
List<TimeRange> timeRanges = Arrays.asList(
new TimeRange(date.atTime(13, 0), date.atTime(14, 0)),
new TimeRange(date.atTime(9, 0), date.atTime(11, 0)),
new TimeRange(date.atTime(10, 0), date.atTime(13, 0)),
new TimeRange(date.atTime(16, 0), date.atTime(17, 0)));
// 调用
List<TimeRange> result = LocalDateTimeUtils.mergeTimeRanges(timeRanges);
// 断言:重叠或首尾相接的时段被合并,独立时段保持不变
assertEquals(2, result.size());
assertTimeRange(CollUtil.getFirst(result), date.atTime(9, 0), date.atTime(14, 0));
assertTimeRange(result.get(1), date.atTime(16, 0), date.atTime(17, 0));
// 调用,并断言:空列表
assertTrue(LocalDateTimeUtils.mergeTimeRanges(Collections.emptyList()).isEmpty());
}
@Test
public void testIntersectTimeRanges() {
// 准备参数
LocalDate date = LocalDate.of(2026, 7, 24);
List<TimeRange> firstRanges = Arrays.asList(
new TimeRange(date.atTime(9, 0), date.atTime(12, 0)),
new TimeRange(date.atTime(13, 0), date.atTime(18, 0)));
List<TimeRange> secondRanges = Arrays.asList(
new TimeRange(date.atTime(10, 0), date.atTime(14, 0)),
new TimeRange(date.atTime(17, 0), date.atTime(19, 0)));
// 调用
List<TimeRange> result = LocalDateTimeUtils.intersectTimeRanges(firstRanges, secondRanges);
// 断言
assertEquals(3, result.size());
assertTimeRange(CollUtil.getFirst(result), date.atTime(10, 0), date.atTime(12, 0));
assertTimeRange(result.get(1), date.atTime(13, 0), date.atTime(14, 0));
assertTimeRange(result.get(2), date.atTime(17, 0), date.atTime(18, 0));
// 调用,并断言:两个时段仅首尾相接时不生成零时长交集
assertNull(LocalDateTimeUtils.intersectTimeRange(
new TimeRange(date.atTime(9, 0), date.atTime(10, 0)),
new TimeRange(date.atTime(10, 0), date.atTime(11, 0))));
}
@Test
public void testCalculateDurationMinutes() {
// 准备参数
LocalDate date = LocalDate.of(2026, 7, 24);
List<TimeRange> timeRanges = Arrays.asList(
new TimeRange(date.atTime(9, 0), date.atTime(10, 0)),
new TimeRange(date.atTime(13, 0), date.atTime(14, 30)));
// 调用,并断言
assertEquals(150, LocalDateTimeUtils.calculateDurationMinutes(timeRanges));
// 调用,并断言:空列表
assertEquals(0, LocalDateTimeUtils.calculateDurationMinutes(Collections.emptyList()));
}
@Test
public void testBuildDailyTimeRanges() {
// 准备参数
LocalDate beginDate = LocalDate.of(2026, 7, 24);
LocalDate endDate = LocalDate.of(2026, 7, 25);
// 调用:构建跨天时间范围
List<TimeRange> result = LocalDateTimeUtils.buildDailyTimeRanges(
beginDate, endDate, LocalTime.of(22, 0), LocalTime.of(6, 0));
// 断言
assertEquals(2, result.size());
assertTimeRange(CollUtil.getFirst(result), beginDate.atTime(22, 0), beginDate.plusDays(1).atTime(6, 0));
assertTimeRange(result.get(1), endDate.atTime(22, 0), endDate.plusDays(1).atTime(6, 0));
// 调用:每日开始和结束时刻相同
result = LocalDateTimeUtils.buildDailyTimeRanges(
beginDate, beginDate, LocalTime.of(9, 0), LocalTime.of(9, 0));
// 断言:按完整一天处理
assertEquals(1, result.size());
assertTimeRange(CollUtil.getFirst(result), beginDate.atTime(9, 0), beginDate.plusDays(1).atTime(9, 0));
// 调用,并断言:开始日期晚于结束日期
assertTrue(LocalDateTimeUtils.buildDailyTimeRanges(
endDate, beginDate, LocalTime.of(9, 0), LocalTime.of(18, 0)).isEmpty());
}
@Test
public void testFindDailyTimeRange() {
// 准备参数
LocalDate date = LocalDate.of(2026, 7, 8);
// 调用,并断言普通时间范围的左右边界
TimeRange result = LocalDateTimeUtils.findDailyTimeRange(
date, LocalTime.of(8, 0), LocalTime.of(10, 0), date.atTime(8, 0));
assertEquals(date.atTime(8, 0), result.getStartTime());
assertEquals(date.atTime(10, 0), result.getEndTime());
assertNotNull(LocalDateTimeUtils.findDailyTimeRange(
date, LocalTime.of(8, 0), LocalTime.of(10, 0), date.atTime(10, 0)));
// 调用,并断言跨日时间范围
result = LocalDateTimeUtils.findDailyTimeRange(
date, LocalTime.of(20, 0), LocalTime.of(6, 0), date.plusDays(1).atTime(5, 0));
assertEquals(date.atTime(20, 0), result.getStartTime());
assertEquals(date.plusDays(1).atTime(6, 0), result.getEndTime());
assertNull(LocalDateTimeUtils.findDailyTimeRange(
date, LocalTime.of(8, 0), LocalTime.of(10, 0), date.atTime(12, 0)));
}
private static void assertTimeRange(TimeRange timeRange,
LocalDateTime expectedStartTime,
LocalDateTime expectedEndTime) {
assertEquals(expectedStartTime, timeRange.getStartTime());
assertEquals(expectedEndTime, timeRange.getEndTime());
}
}
@@ -0,0 +1,207 @@
package cn.iocoder.yudao.framework.excel.core.convert;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.idev.excel.converters.Converter;
import cn.idev.excel.enums.CellDataTypeEnum;
import cn.idev.excel.metadata.GlobalConfiguration;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Excel 多值数据字典转换器
*
* 数据库存储值使用半角逗号分隔,例如 {@code 1,2}
* Excel 展示使用顿号分隔,例如 {@code 男、女}
* 使用时,需要在字段上同时配置
* {@code @ExcelProperty(converter = MultiDictConvert.class)} 和 {@link DictFormat}
*
* @author NaCl
*/
@Slf4j
public class MultiDictConvert implements Converter<Object> {
private static final String EXCEL_SEPARATOR = "、";
private static final String DB_SEPARATOR = ",";
private static final String EXCEL_SEPARATOR_REGEX = "[、,,]";
@Override
public Class<?> supportJavaTypeKey() {
throw new UnsupportedOperationException("暂不支持,也不需要");
}
@Override
public CellDataTypeEnum supportExcelTypeKey() {
throw new UnsupportedOperationException("暂不支持,也不需要");
}
@Override
public Object convertToJavaData(ReadCellData readCellData, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
// 空时,返回空值
Field field = contentProperty.getField();
String labels = readCellData.getStringValue();
if (StrUtil.isBlank(labels)) {
return convertToFieldValue(field, new ArrayList<>());
}
// 使用字典解析
String type = getType(contentProperty);
String[] labelArray = labels.split(EXCEL_SEPARATOR_REGEX);
List<String> values = new ArrayList<>(labelArray.length);
for (String item : labelArray) {
String label = item.trim();
if (StrUtil.isBlank(label)) {
continue;
}
String value = DictFrameworkUtils.parseDictDataValue(type, label);
if (value == null) {
log.error("[convertToJavaData][type({}) 解析不掉 label({})]", type, label);
return null;
}
values.add(value);
}
// 将 String 的 value 转换成对应的属性
return convertToFieldValue(field, values);
}
@Override
public WriteCellData<String> convertToExcelData(Object object, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
// 空时,返回空
if (object == null) {
return new WriteCellData<>("");
}
// 使用字典格式化
String type = getType(contentProperty);
List<String> values = convertToStringList(object);
List<String> labels = new ArrayList<>(values.size());
for (String value : values) {
String label = DictFrameworkUtils.parseDictDataLabel(type, value);
if (label == null) {
log.error("[convertToExcelData][type({}) 转换不了 value({})]", type, value);
return new WriteCellData<>("");
}
labels.add(label);
}
// 生成 Excel 小表格
return new WriteCellData<>(String.join(EXCEL_SEPARATOR, labels));
}
private static Object convertToFieldValue(Field field, List<String> values) {
Class<?> fieldClazz = field.getType();
if (String.class == fieldClazz) {
return String.join(DB_SEPARATOR, values);
}
if (fieldClazz.isArray()) {
return convertToArray(fieldClazz.getComponentType(), values);
}
if (Collection.class.isAssignableFrom(fieldClazz)) {
return convertToCollection(field, values);
}
return Convert.convert(fieldClazz, String.join(DB_SEPARATOR, values));
}
private static Object convertToArray(Class<?> componentType, List<String> values) {
Object array = Array.newInstance(componentType, values.size());
for (int i = 0; i < values.size(); i++) {
Array.set(array, i, Convert.convert(componentType, values.get(i)));
}
return array;
}
private static Collection<?> convertToCollection(Field field, List<String> values) {
Class<?> elementClazz = getCollectionElementClazz(field);
Collection<Object> result = createCollection(field.getType());
for (String value : values) {
result.add(Convert.convert(elementClazz, value));
}
return result;
}
@SuppressWarnings("unchecked")
private static Collection<Object> createCollection(Class<?> fieldClazz) {
if (!fieldClazz.isInterface() && !Modifier.isAbstract(fieldClazz.getModifiers())) {
try {
return (Collection<Object>) fieldClazz.getDeclaredConstructor().newInstance();
} catch (Exception ignored) {
// 使用默认集合实现
}
}
return Set.class.isAssignableFrom(fieldClazz) ? new LinkedHashSet<>() : new ArrayList<>();
}
private static Class<?> getCollectionElementClazz(Field field) {
Type genericType = field.getGenericType();
if (!(genericType instanceof ParameterizedType)) {
return String.class;
}
Type actualType = ((ParameterizedType) genericType).getActualTypeArguments()[0];
if (actualType instanceof Class<?>) {
return (Class<?>) actualType;
}
if (actualType instanceof ParameterizedType
&& ((ParameterizedType) actualType).getRawType() instanceof Class<?>) {
return (Class<?>) ((ParameterizedType) actualType).getRawType();
}
return String.class;
}
private static List<String> convertToStringList(Object object) {
List<String> values = new ArrayList<>();
if (object instanceof String) {
String[] valueArray = ((String) object).split(DB_SEPARATOR);
for (String value : valueArray) {
addStringValue(values, value);
}
return values;
}
if (object instanceof Collection<?>) {
for (Object item : (Collection<?>) object) {
addStringValue(values, item);
}
return values;
}
if (object.getClass().isArray()) {
int length = Array.getLength(object);
for (int i = 0; i < length; i++) {
addStringValue(values, Array.get(object, i));
}
return values;
}
addStringValue(values, object);
return values;
}
private static void addStringValue(List<String> values, Object value) {
if (value == null) {
return;
}
String str = String.valueOf(value).trim();
if (StrUtil.isBlank(str)) {
return;
}
values.add(str);
}
private static String getType(ExcelContentProperty contentProperty) {
return contentProperty.getField().getAnnotation(DictFormat.class).value();
}
}
@@ -0,0 +1,296 @@
package cn.iocoder.yudao.framework.excel.core.convert;
import cn.hutool.core.collection.ListUtil;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty;
import cn.iocoder.yudao.framework.common.biz.system.dict.DictDataCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.dict.dto.DictDataRespDTO;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils;
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* {@link MultiDictConvert} 的单元测试
*
* @author NaCl
*/
public class MultiDictConvertTest extends BaseMockitoUnitTest {
private static final String DICT_TYPE = "test_dict";
@Mock
private DictDataCommonApi dictDataApi;
private MultiDictConvert convert;
@BeforeEach
public void setUp() {
convert = new MultiDictConvert();
DictFrameworkUtils.init(dictDataApi);
DictFrameworkUtils.clearCache();
}
@Test
public void testConvertToExcelData_string() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData("1,2", mockContentProperty("stringValues"), null);
// 断言
assertEquals("男、女", result.getStringValue());
}
@Test
public void testConvertToExcelData_single() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData("1", mockContentProperty("stringValues"), null);
// 断言
assertEquals("男", result.getStringValue());
}
@Test
public void testConvertToExcelData_order() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData("2,1", mockContentProperty("stringValues"), null);
// 断言
assertEquals("女、男", result.getStringValue());
}
@Test
public void testConvertToExcelData_null() {
// 调用
WriteCellData<String> result = convert.convertToExcelData(null, null, null);
// 断言
assertEquals("", result.getStringValue());
}
@Test
public void testConvertToExcelData_collection() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData(ListUtil.of(1, 2),
mockContentProperty("listValues"), null);
// 断言
assertEquals("男、女", result.getStringValue());
}
@Test
public void testConvertToExcelData_array() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData(new int[]{1, 2},
mockContentProperty("stringValues"), null);
// 断言
assertEquals("男、女", result.getStringValue());
}
@Test
public void testConvertToExcelData_unknownValue() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData("1,3", mockContentProperty("stringValues"), null);
// 断言
assertEquals("", result.getStringValue());
}
@Test
public void testConvertToJavaData_string() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
Object result = convert.convertToJavaData(mockReadCellData("男、女"),
mockContentProperty("stringValues"), null);
// 断言
assertEquals("1,2", result);
}
@Test
public void testConvertToJavaData_single() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
Object result = convert.convertToJavaData(mockReadCellData("男"),
mockContentProperty("stringValues"), null);
// 断言
assertEquals("1", result);
}
@Test
public void testConvertToJavaData_blank() throws NoSuchFieldException {
// 调用
Object result = convert.convertToJavaData(mockReadCellData(""),
mockContentProperty("stringValues"), null);
// 断言
assertEquals("", result);
}
@Test
public void testConvertToJavaData_blankList() throws NoSuchFieldException {
// 调用
Object result = convert.convertToJavaData(mockReadCellData(""),
mockContentProperty("listValues"), null);
// 断言
assertTrue(result instanceof List);
assertTrue(((List<?>) result).isEmpty());
}
@Test
public void testConvertToJavaData_blankSet() throws NoSuchFieldException {
// 调用
Object result = convert.convertToJavaData(mockReadCellData(""),
mockContentProperty("setValues"), null);
// 断言
assertTrue(result instanceof LinkedHashSet);
assertTrue(((Set<?>) result).isEmpty());
}
@Test
public void testConvertToJavaData_blankArray() throws NoSuchFieldException {
// 调用
Object result = convert.convertToJavaData(mockReadCellData(""),
mockContentProperty("arrayValues"), null);
// 断言
assertArrayEquals(new Integer[0], (Integer[]) result);
}
@Test
@SuppressWarnings("unchecked")
public void testConvertToJavaData_list() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
Object result = convert.convertToJavaData(mockReadCellData("男、女"),
mockContentProperty("listValues"), null);
// 断言
assertEquals(ListUtil.of(1, 2), (List<Integer>) result);
}
@Test
@SuppressWarnings("unchecked")
public void testConvertToJavaData_set() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
Object result = convert.convertToJavaData(mockReadCellData("男,女"),
mockContentProperty("setValues"), null);
// 断言
assertEquals(new LinkedHashSet<>(Arrays.asList(1L, 2L)), (Set<Long>) result);
}
@Test
@SuppressWarnings("SimplifiableAssertion")
public void testConvertToJavaData_hashSet() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
Object result = convert.convertToJavaData(mockReadCellData("男,女"),
mockContentProperty("hashSetValues"), null);
// 断言
assertTrue(result instanceof HashSet);
assertEquals(new HashSet<>(Arrays.asList(1L, 2L)), result);
}
@Test
public void testConvertToJavaData_array() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
Object result = convert.convertToJavaData(mockReadCellData("男,女"),
mockContentProperty("arrayValues"), null);
// 断言
assertArrayEquals(new Integer[]{1, 2}, (Integer[]) result);
}
@Test
public void testConvertToJavaData_unknownLabel() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
Object result = convert.convertToJavaData(mockReadCellData("男、未知"),
mockContentProperty("stringValues"), null);
// 断言
assertNull(result);
}
private void mockDictDatas() {
List<DictDataRespDTO> dictDatas = ListUtil.of(
randomPojo(DictDataRespDTO.class, o -> o.setDictType(DICT_TYPE).setValue("1").setLabel("男")),
randomPojo(DictDataRespDTO.class, o -> o.setDictType(DICT_TYPE).setValue("2").setLabel("女"))
);
when(dictDataApi.getDictDataList(eq(DICT_TYPE))).thenReturn(CommonResult.success(dictDatas));
}
private static ReadCellData<?> mockReadCellData(String value) {
ReadCellData<?> readCellData = mock(ReadCellData.class);
when(readCellData.getStringValue()).thenReturn(value);
return readCellData;
}
private static ExcelContentProperty mockContentProperty(String fieldName) throws NoSuchFieldException {
Field field = TestExcelVO.class.getDeclaredField(fieldName);
ExcelContentProperty contentProperty = mock(ExcelContentProperty.class);
when(contentProperty.getField()).thenReturn(field);
return contentProperty;
}
static class TestExcelVO {
@DictFormat(DICT_TYPE)
private String stringValues;
@DictFormat(DICT_TYPE)
private List<Integer> listValues;
@DictFormat(DICT_TYPE)
private Set<Long> setValues;
@DictFormat(DICT_TYPE)
private HashSet<Long> hashSetValues;
@DictFormat(DICT_TYPE)
private Integer[] arrayValues;
}
}
@@ -0,0 +1,61 @@
package cn.iocoder.yudao.framework.excel.core.util;
import cn.idev.excel.FastExcelFactory;
import cn.idev.excel.annotation.ExcelProperty;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* {@link ExcelUtils} 的单元测试
*
* @author 芋道源码
*/
public class ExcelUtilsTest {
@Test
public void testRead_maxRowCount() throws Exception {
// mock 数据
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
FastExcelFactory.write(outputStream, TestRow.class).sheet().doWrite(Arrays.asList(
new TestRow("员工一"), new TestRow("员工二"), new TestRow("员工三")));
MockMultipartFile file = new MockMultipartFile(
"file", "employees.xlsx", null, outputStream.toByteArray());
// 调用
List<TestRow> rows = ExcelUtils.read(file, TestRow.class, 2);
// 断言
assertEquals(2, rows.size());
assertEquals("员工一", rows.get(0).getName());
assertEquals("员工二", rows.get(1).getName());
}
public static class TestRow {
@ExcelProperty("姓名")
private String name;
public TestRow() {
}
public TestRow(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}
@@ -102,16 +102,9 @@ public class QueryWrapperX<T> extends QueryWrapper<T> {
}
public QueryWrapperX<T> betweenIfPresent(String column, Object[] values) {
if (values != null && values.length != 0 && values[0] != null && values[1] != null) {
return (QueryWrapperX<T>) super.between(column, values[0], values[1]);
}
if (values != null && values.length != 0 && values[0] != null) {
return (QueryWrapperX<T>) ge(column, values[0]);
}
if (values != null && values.length != 0 && values[1] != null) {
return (QueryWrapperX<T>) le(column, values[1]);
}
return this;
Object val1 = values != null && values.length > 0 ? values[0] : null;
Object val2 = values != null && values.length > 1 ? values[1] : null;
return betweenIfPresent(column, val1, val2);
}
// ========== 重写父类方法,方便链式调用 ==========
@@ -24,6 +24,8 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* MyBatis 工具类
@@ -179,6 +181,28 @@ public class MyBatisUtils {
return findInSet(columnName, paramIndex);
}
/**
* 跨数据库的 find_in_set 实现,适用于同一字段匹配多个参数的场景
*
* 每个参数生成一个 find_in_set 条件,并使用 OR 连接。
*
* @param columnName 字段名称
* @param values 参数集合
* @return sql
*/
public static String findInSet(String columnName, Collection<?> values) {
return findInSet(JdbcUtils.getDbType(), columnName, values);
}
static String findInSet(DbType dbType, String columnName, Collection<?> values) {
if (CollUtil.isEmpty(values)) {
throw new IllegalArgumentException("Values cannot be empty");
}
return IntStream.range(0, values.size())
.mapToObj(index -> findInSet(dbType, columnName, index))
.collect(Collectors.joining(" OR "));
}
private static String findInSet(String columnName, int paramIndex) {
DbType dbType = JdbcUtils.getDbType();
return findInSet(dbType, columnName, paramIndex);
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.framework.mybatis.core.query;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@link QueryWrapperX} 的单元测试
*
* @author 芋道源码
*/
public class QueryWrapperXTest {
@Test
public void testBetweenIfPresent_array() {
// 调用,并断言:空范围不追加条件
assertEquals("", new QueryWrapperX<>().betweenIfPresent("create_time", (Object[]) null).getSqlSegment());
assertEquals("", new QueryWrapperX<>().betweenIfPresent("create_time", new Object[0]).getSqlSegment());
// 调用,并断言:单边范围安全降级为大于等于或小于等于
assertTrue(new QueryWrapperX<>().betweenIfPresent("create_time", new Object[]{1})
.getSqlSegment().contains("create_time >="));
assertTrue(new QueryWrapperX<>().betweenIfPresent("create_time", new Object[]{null, 2})
.getSqlSegment().contains("create_time <="));
// 调用,并断言:双边范围使用左右闭合的 BETWEEN
assertTrue(new QueryWrapperX<>().betweenIfPresent("create_time", new Object[]{1, 2})
.getSqlSegment().contains("create_time BETWEEN"));
}
}
@@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -128,6 +129,14 @@ public class MyBatisUtilsTest {
() -> MyBatisUtils.findInSet(DbType.MYSQL, "websites", -1));
}
@Test
public void testFindInSet_multipleValues() {
assertEquals("FIND_IN_SET({0}, tag_ids) <> 0 OR FIND_IN_SET({1}, tag_ids) <> 0",
MyBatisUtils.findInSet(DbType.MYSQL, "tag_ids", Arrays.asList(1L, 2L)));
assertThrows(IllegalArgumentException.class,
() -> MyBatisUtils.findInSet(DbType.MYSQL, "tag_ids", Collections.emptyList()));
}
@Test
public void testFindInSet_applyBindsValue() {
// 准备参数
@@ -15,7 +15,7 @@ public class FmsAccountSetRespVO {
@Schema(description = "公司编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "WK001")
private String companyCode;
@Schema(description = "公司名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "悟空科技有限公司")
@Schema(description = "公司名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试科技有限公司")
private String companyName;
@Schema(description = "公司简介", example = "专注企业数字化服务")
@@ -20,7 +20,7 @@ public class FmsAccountSetSaveReqVO {
@DiffLogField(name = "公司编码")
private String companyCode;
@Schema(description = "公司名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "悟空科技有限公司")
@Schema(description = "公司名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试科技有限公司")
@NotBlank(message = "公司名称不能为空")
@Size(max = 255, message = "公司名称不能超过 255 个字符")
@DiffLogField(name = "公司名称")
@@ -24,7 +24,7 @@ public class FmsAuxiliaryItemPageReqVO extends PageParam {
@NotNull(message = "辅助核算类别编号不能为空")
private Long auxiliaryTypeId;
@Schema(description = "关键词,匹配编码或名称", example = "悟空")
@Schema(description = "关键词,匹配编码或名称", example = "测试")
private String search;
}
@@ -30,7 +30,7 @@ public class FmsAuxiliaryItemRespVO {
@ExcelProperty("编码")
private String code;
@Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "上海悟空数字科技有限公司")
@Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "上海测试数字科技有限公司")
@ExcelProperty("名称")
private String name;
@@ -31,7 +31,7 @@ public class FmsAuxiliaryItemSaveReqVO {
@Size(max = 64, message = "编码长度不能超过 64 个字符")
private String code;
@Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "上海悟空数字科技有限公司")
@Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "上海测试数字科技有限公司")
@NotBlank(message = "名称不能为空")
@Size(max = 255, message = "名称长度不能超过 255 个字符")
private String name;
@@ -180,7 +180,7 @@ public class FmsFinanceParameterServiceImpl implements FmsFinanceParameterServic
/**
* 校验财务参数只扩展科目层级和各级编码长度,避免已使用的科目编码失效
*
* 每级编码长度限制为 2 至 5 位,与悟空财务的科目编码输入范围保持一致
* 每级编码长度限制为 2 至 5 位,与既有财务规则保持一致
*
* @param oldLevel 原科目层级
* @param oldRules 原科目编码规则
@@ -569,7 +569,7 @@ public class FmsCashFlowStatementServiceImpl implements FmsCashFlowStatementServ
/**
* 获得现金流量表金额
*
* 悟空财务使用非零金额覆盖公式计算结果,保存零值时重新按公式计算
* 既有财务规则使用非零金额覆盖公式计算结果,保存零值时重新按公式计算
*
* @param savedAmount 已保存金额
* @param calculatedAmount 公式计算金额
@@ -151,7 +151,7 @@ public interface FmsReportCommonService {
FmsLedgerSubjectBalanceRespVO balance, boolean current);
/**
* 计算利润表发生额,正向公式项按悟空语义抵减反方向发生额
* 计算利润表发生额,正向公式项按既有业务语义抵减反方向发生额
*
* @param rule 取数规则
* @param subject 科目
@@ -58,6 +58,8 @@ public class FmsAccountSetServiceImplTest extends BaseDbUnitTest {
@MockitoBean
private FmsFinanceParameterService financeParameterService;
@MockitoBean
private FmsFinanceIndicatorService financeIndicatorService;
@MockitoBean
private FmsVoucherWordService voucherWordService;
@MockitoBean
private FmsAuxiliaryTypeService auxiliaryTypeService;
@@ -72,7 +74,7 @@ public class FmsAccountSetServiceImplTest extends BaseDbUnitTest {
public void testCreateAccountSet_success() {
// 准备参数
Long userId = randomLongId();
FmsAccountSetSaveReqVO reqVO = buildAccountSetSaveReqVO("WK001", "悟空科技有限公司");
FmsAccountSetSaveReqVO reqVO = buildAccountSetSaveReqVO("TEST001", "测试科技有限公司");
// 调用
Long accountSetId = accountSetService.createAccountSet(reqVO, userId);
@@ -59,7 +59,7 @@ public class FmsAuxiliaryItemServiceImplTest extends BaseDbUnitTest {
// mock 方法
mockAuxiliaryType(1L, 11L);
// 准备参数
FmsAuxiliaryItemSaveReqVO reqVO = buildSaveReqVO(1L, 11L, "KH001", "悟空客户");
FmsAuxiliaryItemSaveReqVO reqVO = buildSaveReqVO(1L, 11L, "KH001", "测试客户");
// 调用
Long auxiliaryItemId = auxiliaryItemService.createAuxiliaryItem(reqVO, 10L);
@@ -67,7 +67,7 @@ public class FmsAuxiliaryItemServiceImplTest extends BaseDbUnitTest {
// 断言
FmsAuxiliaryItemDO auxiliaryItem = auxiliaryItemMapper.selectById(auxiliaryItemId);
assertEquals("KH001", auxiliaryItem.getCode());
assertEquals("悟空客户", auxiliaryItem.getName());
assertEquals("测试客户", auxiliaryItem.getName());
assertEquals(CommonStatusEnum.ENABLE.getStatus(), auxiliaryItem.getStatus());
assertEquals(11L, auxiliaryItem.getAuxiliaryTypeId());
verify(accountSetService).validateAccountSetWritePermission(1L, 10L);
@@ -89,7 +89,7 @@ public class FmsAuxiliaryItemServiceImplTest extends BaseDbUnitTest {
@Test
public void testDeleteAuxiliaryItemList_success() {
// mock 数据
FmsAuxiliaryItemDO auxiliaryItem = buildAuxiliaryItem(1L, 11L, "KH001", "悟空客户");
FmsAuxiliaryItemDO auxiliaryItem = buildAuxiliaryItem(1L, 11L, "KH001", "测试客户");
auxiliaryItemMapper.insert(auxiliaryItem);
// 调用
@@ -105,7 +105,7 @@ public class FmsAuxiliaryItemServiceImplTest extends BaseDbUnitTest {
@Test
public void testDeleteAuxiliaryItemList_voucherTemplateInUse() {
// mock 数据
FmsAuxiliaryItemDO auxiliaryItem = buildAuxiliaryItem(1L, 11L, "KH001", "悟空客户");
FmsAuxiliaryItemDO auxiliaryItem = buildAuxiliaryItem(1L, 11L, "KH001", "测试客户");
auxiliaryItemMapper.insert(auxiliaryItem);
// mock 方法
when(voucherTemplateService.getVoucherTemplateCountByAuxiliaryItemIds(
@@ -120,7 +120,7 @@ public class FmsAuxiliaryItemServiceImplTest extends BaseDbUnitTest {
@Test
public void testUpdateAuxiliaryItemStatus_success() {
// mock 数据
FmsAuxiliaryItemDO auxiliaryItem = buildAuxiliaryItem(1L, 11L, "KH001", "悟空客户");
FmsAuxiliaryItemDO auxiliaryItem = buildAuxiliaryItem(1L, 11L, "KH001", "测试客户");
auxiliaryItemMapper.insert(auxiliaryItem);
// 调用
@@ -137,13 +137,13 @@ public class FmsAuxiliaryItemServiceImplTest extends BaseDbUnitTest {
public void testGetAuxiliaryItemPage_search() {
// mock 数据
mockAuxiliaryType(1L, 11L);
auxiliaryItemMapper.insert(buildAuxiliaryItem(1L, 11L, "KH001", "上海悟空"));
auxiliaryItemMapper.insert(buildAuxiliaryItem(1L, 11L, "KH001", "上海测试"));
auxiliaryItemMapper.insert(buildAuxiliaryItem(1L, 11L, "KH002", "北京云财务"));
// 准备参数
FmsAuxiliaryItemPageReqVO reqVO = new FmsAuxiliaryItemPageReqVO();
reqVO.setAccountSetId(1L);
reqVO.setAuxiliaryTypeId(11L);
reqVO.setSearch("悟空");
reqVO.setSearch("测试");
// 调用
PageResult<FmsAuxiliaryItemDO> pageResult = auxiliaryItemService
@@ -212,7 +212,7 @@ public class FmsAuxiliaryItemServiceImplTest extends BaseDbUnitTest {
.thenReturn(Collections.singletonList(new FmsAuxiliaryTypeDO()
.setId(16L).setAccountSetId(1L).setName("存货").setSystemPreset(true)
.setType(FmsAuxiliaryTypeEnum.INVENTORY.getType())));
FmsAuxiliaryItemImportExcelVO row = buildImportRow("SP001", "悟空商品");
FmsAuxiliaryItemImportExcelVO row = buildImportRow("SP001", "测试商品");
row.setRemark("库存备注");
row.setSpecification("标准版");
row.setUnit("台");
@@ -10,7 +10,7 @@ import org.springframework.stereotype.Component;
*
* @author 芋道源码
*/
@Component
@Component("hrmAreaParseFunction")
public class HrmAreaParseFunction implements IParseFunction {
public static final String NAME = "getArea";
@@ -328,9 +328,7 @@ public class HrmAttendanceGroupServiceImpl implements HrmAttendanceGroupService
// 1. 计算当前组接管的部门及员工范围
Set<Long> deptIds = new HashSet<>(reqVO.getDeptIds());
Set<Long> deptScope = new HashSet<>(deptIds);
for (Long deptId : deptIds) {
deptScope.addAll(convertSet(deptApi.getChildDeptList(deptId).getCheckedData(), DeptRespDTO::getId));
}
deptScope.addAll(convertSet(deptApi.getChildDeptList(deptIds).getCheckedData(), DeptRespDTO::getId));
Set<Long> employeeIds = new HashSet<>(reqVO.getEmployeeIds());
if (CollUtil.isNotEmpty(deptScope)) {
employeeIds.addAll(convertSet(
@@ -414,11 +412,7 @@ public class HrmAttendanceGroupServiceImpl implements HrmAttendanceGroupService
Map<Long, List<Long>> result = new HashMap<>();
for (Long deptId : validDeptIds) {
List<Long> hierarchy = new ArrayList<>(Collections.singletonList(deptId));
DeptRespDTO dept = deptApi.getDept(deptId).getCheckedData();
while (dept != null && dept.getParentId() != null && dept.getParentId() > 0) {
hierarchy.add(dept.getParentId());
dept = deptApi.getDept(dept.getParentId()).getCheckedData();
}
hierarchy.addAll(convertList(deptApi.getParentDeptList(deptId).getCheckedData(), DeptRespDTO::getId));
result.put(deptId, hierarchy);
}
return result;
@@ -20,7 +20,6 @@ import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.Collection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@@ -180,15 +179,8 @@ public class HrmSalaryGroupServiceImpl implements HrmSalaryGroupService {
if (salaryGroup != null) {
return salaryGroup;
}
List<Long> parentDeptIds = parentDeptIdCache.computeIfAbsent(employeeDeptId, deptId -> {
List<Long> result = new ArrayList<>();
DeptRespDTO dept = deptApi.getDept(deptId).getCheckedData();
while (dept != null && dept.getParentId() != null && dept.getParentId() > 0) {
result.add(dept.getParentId());
dept = deptApi.getDept(dept.getParentId()).getCheckedData();
}
return result;
});
List<Long> parentDeptIds = parentDeptIdCache.computeIfAbsent(employeeDeptId,
deptId -> convertList(deptApi.getParentDeptList(deptId).getCheckedData(), DeptRespDTO::getId));
for (Long parentDeptId : parentDeptIds) {
salaryGroup = deptSalaryGroupMap.get(parentDeptId);
if (salaryGroup != null) {
@@ -257,9 +249,7 @@ public class HrmSalaryGroupServiceImpl implements HrmSalaryGroupService {
return Collections.emptySet();
}
Set<Long> result = new HashSet<>(deptIds);
for (Long deptId : deptIds) {
result.addAll(convertSet(deptApi.getChildDeptList(deptId).getCheckedData(), DeptRespDTO::getId));
}
result.addAll(convertSet(deptApi.getChildDeptList(deptIds).getCheckedData(), DeptRespDTO::getId));
return result;
}
@@ -0,0 +1,147 @@
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager;
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientPollScheduler.ReadSegment;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_COILS;
import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_HOLDING_REGISTERS;
import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_INPUT_REGISTERS;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link IotModbusTcpClientPollScheduler} 的单元测试
*
* @author 芋道源码
*/
public class IotModbusTcpClientPollSchedulerTest {
@Test
public void testBuildReadSegments_mergeContinuousPoints() {
// 准备参数
IotModbusPointRespDTO point01 = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 0, 1, 1000);
IotModbusPointRespDTO point02 = randomPoint(2L, FC_READ_HOLDING_REGISTERS, 1, 2, 1000);
IotModbusPointRespDTO point03 = randomPoint(3L, FC_READ_HOLDING_REGISTERS, 4, 1, 1000);
IotModbusDeviceConfigRespDTO config = randomConfig(point01, point02, point03);
// 调用
List<ReadSegment> segments = IotModbusTcpClientPollScheduler.buildReadSegments(config);
// 断言
assertEquals(2, segments.size());
assertEquals(0, segments.get(0).getStartAddress());
assertEquals(3, segments.get(0).getRegisterCount());
assertIterableEquals(Arrays.asList(point01, point02), segments.get(0).getPoints());
assertEquals(4, segments.get(1).getStartAddress());
assertEquals(1, segments.get(1).getRegisterCount());
assertIterableEquals(Collections.singletonList(point03), segments.get(1).getPoints());
}
@Test
public void testBuildReadSegments_mergeOverlappingPoints() {
// 准备参数
IotModbusPointRespDTO point01 = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 0, 2, 1000);
IotModbusPointRespDTO point02 = randomPoint(2L, FC_READ_HOLDING_REGISTERS, 1, 1, 1000);
IotModbusDeviceConfigRespDTO config = randomConfig(point01, point02);
// 调用
List<ReadSegment> segments = IotModbusTcpClientPollScheduler.buildReadSegments(config);
// 断言
assertEquals(1, segments.size());
assertEquals(0, segments.get(0).getStartAddress());
assertEquals(2, segments.get(0).getRegisterCount());
assertIterableEquals(Arrays.asList(point01, point02), segments.get(0).getPoints());
}
@Test
public void testBuildReadSegments_notMergeDifferentFunctionCodeOrPollInterval() {
// 准备参数
IotModbusPointRespDTO point01 = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 0, 1, 1000);
IotModbusPointRespDTO point02 = randomPoint(2L, FC_READ_INPUT_REGISTERS, 1, 1, 1000);
IotModbusPointRespDTO point03 = randomPoint(3L, FC_READ_HOLDING_REGISTERS, 1, 1, 2000);
IotModbusDeviceConfigRespDTO config = randomConfig(point01, point02, point03);
// 调用
List<ReadSegment> segments = IotModbusTcpClientPollScheduler.buildReadSegments(config);
// 断言
assertEquals(3, segments.size());
assertEquals(FC_READ_HOLDING_REGISTERS, segments.get(0).getFunctionCode());
assertEquals(1000, segments.get(0).getPollInterval());
assertEquals(FC_READ_HOLDING_REGISTERS, segments.get(1).getFunctionCode());
assertEquals(2000, segments.get(1).getPollInterval());
assertEquals(FC_READ_INPUT_REGISTERS, segments.get(2).getFunctionCode());
assertEquals(1000, segments.get(2).getPollInterval());
}
@Test
public void testBuildReadSegments_splitWhenExceedsMaxRegisterCount() {
// 准备参数
IotModbusPointRespDTO point01 = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 0, 100, 1000);
IotModbusPointRespDTO point02 = randomPoint(2L, FC_READ_HOLDING_REGISTERS, 100, 30, 1000);
IotModbusDeviceConfigRespDTO config = randomConfig(point01, point02);
// 调用
List<ReadSegment> segments = IotModbusTcpClientPollScheduler.buildReadSegments(config);
// 断言
assertEquals(2, segments.size());
assertEquals(0, segments.get(0).getStartAddress());
assertEquals(100, segments.get(0).getRegisterCount());
assertEquals(100, segments.get(1).getStartAddress());
assertEquals(30, segments.get(1).getRegisterCount());
}
@Test
public void testExtractPointRawValues() {
// 准备参数
IotModbusPointRespDTO point = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 12, 2, 1000);
ReadSegment segment = new ReadSegment(FC_READ_HOLDING_REGISTERS, 1000, 10, 4, Collections.singletonList(point));
int[] rawValues = new int[]{100, 200, 300, 400};
// 调用
int[] pointRawValues = IotModbusTcpClientPollScheduler.extractPointRawValues(rawValues, segment, point);
// 断言
assertArrayEquals(new int[]{300, 400}, pointRawValues);
}
@Test
public void testExtractPointRawValuesForCoils() {
// 准备参数
IotModbusPointRespDTO point = randomPoint(1L, FC_READ_COILS, 3, 2, 1000);
ReadSegment segment = new ReadSegment(FC_READ_COILS, 1000, 0, 5, Collections.singletonList(point));
int[] rawValues = new int[]{1, 0, 1, 1, 0, 0, 0, 0}; // 线圈响应可能按字节补齐
// 调用
int[] pointRawValues = IotModbusTcpClientPollScheduler.extractPointRawValues(rawValues, segment, point);
// 断言
assertArrayEquals(new int[]{1, 0}, pointRawValues);
}
private static IotModbusDeviceConfigRespDTO randomConfig(IotModbusPointRespDTO... points) {
IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO();
config.setDeviceId(1L);
config.setPoints(Arrays.asList(points));
return config;
}
private static IotModbusPointRespDTO randomPoint(Long id, Integer functionCode, Integer registerAddress,
Integer registerCount, Integer pollInterval) {
IotModbusPointRespDTO point = new IotModbusPointRespDTO();
point.setId(id);
point.setFunctionCode(functionCode);
point.setRegisterAddress(registerAddress);
point.setRegisterCount(registerCount);
point.setPollInterval(pollInterval);
return point;
}
}
@@ -52,4 +52,14 @@ public interface DeptApi {
@Parameter(name = "id", description = "部门编号", example = "1024", required = true)
CommonResult<List<DeptRespDTO>> getChildDeptList(@RequestParam("id") Long id);
@GetMapping(PREFIX + "/list-child-by-ids")
@Operation(summary = "获得指定部门的所有子部门")
@Parameter(name = "ids", description = "部门编号数组", example = "1,2", required = true)
CommonResult<List<DeptRespDTO>> getChildDeptList(@RequestParam("ids") Collection<Long> ids);
@GetMapping(PREFIX + "/list-parent")
@Operation(summary = "获得指定部门的所有父部门")
@Parameter(name = "id", description = "部门编号", example = "1024", required = true)
CommonResult<List<DeptRespDTO>> getParentDeptList(@RequestParam("id") Long id);
}
@@ -45,4 +45,16 @@ public class DeptApiImpl implements DeptApi {
return success(BeanUtils.toBean(depts, DeptRespDTO.class));
}
@Override
public CommonResult<List<DeptRespDTO>> getChildDeptList(Collection<Long> ids) {
List<DeptDO> depts = deptService.getChildDeptList(ids);
return success(BeanUtils.toBean(depts, DeptRespDTO.class));
}
@Override
public CommonResult<List<DeptRespDTO>> getParentDeptList(Long id) {
List<DeptDO> depts = deptService.getParentDeptList(id);
return success(BeanUtils.toBean(depts, DeptRespDTO.class));
}
}
@@ -96,6 +96,16 @@ public interface DeptService {
*/
List<DeptDO> getChildDeptList(Collection<Long> ids);
/**
* 获得指定部门的所有父部门
*
* 按直属父部门到根部门的顺序返回,不包含指定部门自身。
*
* @param id 部门编号
* @return 父部门列表
*/
List<DeptDO> getParentDeptList(Long id);
/**
* 获得指定领导者的部门列表
*
@@ -203,6 +203,29 @@ public class DeptServiceImpl implements DeptService {
return children;
}
@Override
public List<DeptDO> getParentDeptList(Long id) {
List<DeptDO> parents = new ArrayList<>();
Set<Long> visitedDeptIds = new HashSet<>();
visitedDeptIds.add(id);
DeptDO dept = getDept(id);
// TODO DONE @AI:使用 Short.MAX_VALUE 限制父链遍历次数,并通过已访问集合提前结束脏数据环路。
for (int i = 0; i < Short.MAX_VALUE; i++) {
if (dept == null || dept.getParentId() == null
|| ObjectUtil.equal(dept.getParentId(), DeptDO.PARENT_ID_ROOT)
|| visitedDeptIds.contains(dept.getParentId())) {
break;
}
visitedDeptIds.add(dept.getParentId());
dept = getDept(dept.getParentId());
if (dept == null) {
break;
}
parents.add(dept);
}
return parents;
}
@Override
public List<DeptDO> getDeptListByLeaderUserId(Long id) {
return deptMapper.selectListByLeaderUserId(id);