缓存改造:OAuth2Client 使用 Redis 作为缓存

This commit is contained in:
YunaiV 2023-03-02 09:43:03 +08:00
parent 5572bf3fcb
commit 7ccdf86d3a
12 changed files with 103 additions and 239 deletions

View File

@ -26,7 +26,7 @@ public class TenantRedisCacheManager extends TimeoutRedisCacheManager {
*
* 原因如果只补充租户编号可读性较差
*/
private static final String PREFIX = "t";
public static final String PREFIX = "t";
private final TenantProperties tenantProperties;

View File

@ -40,7 +40,7 @@ public class TenantRedisKeyDefine extends RedisKeyDefine {
@Override
public String formatKey(Object... args) {
args = ArrayUtil.append(args, TenantContextHolder.getRequiredTenantId());
args = ArrayUtil.append(args, TenantRedisCacheManager.PREFIX + TenantContextHolder.getRequiredTenantId());
return super.formatKey(args);
}

View File

@ -4,11 +4,13 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.system.controller.admin.permission.vo.menu.MenuListReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.permission.MenuDO;
import com.baomidou.dynamic.datasource.annotation.Master;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
@Master
public interface MenuMapper extends BaseMapperX<MenuDO> {
default MenuDO selectByParentIdAndName(Long parentId, String name) {

View File

@ -14,23 +14,15 @@ import static cn.iocoder.yudao.framework.redis.core.RedisKeyDefine.KeyTypeEnum.S
*/
public interface RedisKeyConstants {
RedisKeyDefine CAPTCHA_CODE = new RedisKeyDefine("验证码的缓存",
"captcha_code:%s", // 参数为 uuid
STRING, String.class, RedisKeyDefine.TimeoutTypeEnum.DYNAMIC);
RedisKeyDefine OAUTH2_ACCESS_TOKEN = new RedisKeyDefine("访问令牌的缓存",
"oauth2_access_token:%s", // 参数为访问令牌 token
STRING, OAuth2AccessTokenDO.class, RedisKeyDefine.TimeoutTypeEnum.DYNAMIC);
RedisKeyDefine SOCIAL_AUTH_STATE = new RedisKeyDefine("社交登陆的 state", // 注意它是被 JustAuth justauth.type.prefix 使用到
"social_auth_state:%s", // 参数为 state
STRING, String.class, Duration.ofHours(24)); // 值为 state
/**
* 指定部门的所有子部门编号数组的缓存
*
* KEY 格式dept_children_ids::{id}
* 数据类型String 子部门编号集合
* VALUE 数据类型String 子部门编号集合
*/
String DEPT_CHILDREN_ID_LIST = "dept_children_ids";
@ -38,7 +30,7 @@ public interface RedisKeyConstants {
* 角色的缓存
*
* KEY 格式role::{id}
* 数据类型String 角色编号
* VALUE 数据类型String 角色信息
*/
String ROLE = "role";
@ -46,7 +38,7 @@ public interface RedisKeyConstants {
* 用户拥有的角色编号的缓存
*
* KEY 格式user_role_ids::{userId}
* 数据类型String 角色编号集合
* VALUE 数据类型String 角色编号集合
*/
String USER_ROLE_ID_LIST = "user_role_ids";
@ -54,7 +46,7 @@ public interface RedisKeyConstants {
* 拥有指定菜单的角色编号的缓存
*
* KEY 格式user_role_ids::{menuId}
* 数据类型String 角色编号集合
* VALUE 数据类型String 角色编号集合
*/
String MENU_ROLE_ID_LIST = "menu_role_ids";
@ -62,8 +54,16 @@ public interface RedisKeyConstants {
* 拥有权限对应的菜单编号数组的缓存
*
* KEY 格式permission_menu_ids::{permission}
* 数据类型String 菜单编号数组
* VALUE 数据类型String 菜单编号数组
*/
String PERMISSION_MENU_ID_LIST = "permission_menu_ids";
/**
* OAuth2 客户端的缓存
*
* KEY 格式user::{id}
* VALUE 数据类型String 客户端信息
*/
String OAUTH_CLIENT = "oauth_client";
}

View File

@ -1,41 +0,0 @@
package cn.iocoder.yudao.module.system.dal.redis.common;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import javax.annotation.Resource;
import java.time.Duration;
import static cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants.CAPTCHA_CODE;
/**
* 验证码的 Redis DAO
*
* @author 芋道源码
*/
@Repository
public class CaptchaRedisDAO {
@Resource
private StringRedisTemplate stringRedisTemplate;
public String get(String uuid) {
String redisKey = formatKey(uuid);
return stringRedisTemplate.opsForValue().get(redisKey);
}
public void set(String uuid, String code, Duration timeout) {
String redisKey = formatKey(uuid);
stringRedisTemplate.opsForValue().set(redisKey, code, timeout);
}
public void delete(String uuid) {
String redisKey = formatKey(uuid);
stringRedisTemplate.delete(redisKey);
}
private static String formatKey(String uuid) {
return String.format(CAPTCHA_CODE.getKeyTemplate(), uuid);
}
}

View File

@ -1,29 +0,0 @@
package cn.iocoder.yudao.module.system.mq.consumer.auth;
import cn.iocoder.yudao.framework.mq.core.pubsub.AbstractChannelMessageListener;
import cn.iocoder.yudao.module.system.mq.message.auth.OAuth2ClientRefreshMessage;
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2ClientService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 针对 {@link OAuth2ClientRefreshMessage} 的消费者
*
* @author 芋道源码
*/
@Component
@Slf4j
public class OAuth2ClientRefreshConsumer extends AbstractChannelMessageListener<OAuth2ClientRefreshMessage> {
@Resource
private OAuth2ClientService oauth2ClientService;
@Override
public void onMessage(OAuth2ClientRefreshMessage message) {
log.info("[onMessage][收到 OAuth2Client 刷新消息]");
oauth2ClientService.initLocalCache();
}
}

View File

@ -1,21 +0,0 @@
package cn.iocoder.yudao.module.system.mq.message.auth;
import cn.iocoder.yudao.framework.mq.core.pubsub.AbstractChannelMessage;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* OAuth 2.0 客户端的数据刷新 Message
*
* @author 芋道源码
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class OAuth2ClientRefreshMessage extends AbstractChannelMessage {
@Override
public String getChannel() {
return "system.oauth2-client.refresh";
}
}

View File

@ -1,26 +0,0 @@
package cn.iocoder.yudao.module.system.mq.producer.auth;
import cn.iocoder.yudao.framework.mq.core.RedisMQTemplate;
import cn.iocoder.yudao.module.system.mq.message.auth.OAuth2ClientRefreshMessage;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* OAuth 2.0 客户端相关消息的 Producer
*/
@Component
public class OAuth2ClientProducer {
@Resource
private RedisMQTemplate redisMQTemplate;
/**
* 发送 {@link OAuth2ClientRefreshMessage} 消息
*/
public void sendOAuth2ClientRefreshMessage() {
OAuth2ClientRefreshMessage message = new OAuth2ClientRefreshMessage();
redisMQTemplate.send(message);
}
}

View File

@ -18,11 +18,6 @@ import java.util.Collection;
*/
public interface OAuth2ClientService {
/**
* 初始化 OAuth2Client 的本地缓存
*/
void initLocalCache();
/**
* 创建 OAuth2 客户端
*
@ -53,6 +48,14 @@ public interface OAuth2ClientService {
*/
OAuth2ClientDO getOAuth2Client(Long id);
/**
* 获得 OAuth2 客户端从缓存中
*
* @param clientId 客户端编号
* @return OAuth2 客户端
*/
OAuth2ClientDO getOAuth2ClientFromCache(String clientId);
/**
* 获得 OAuth2 客户端分页
*
@ -82,7 +85,7 @@ public interface OAuth2ClientService {
* @param redirectUri 重定向地址
* @return 客户端
*/
OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret,
String authorizedGrantType, Collection<String> scopes, String redirectUri);
OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret, String authorizedGrantType,
Collection<String> scopes, String redirectUri);
}

View File

@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.system.service.oauth2;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.string.StrUtils;
@ -12,22 +13,18 @@ import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2Cl
import cn.iocoder.yudao.module.system.convert.auth.OAuth2ClientConvert;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2ClientMapper;
import cn.iocoder.yudao.module.system.mq.producer.auth.OAuth2ClientProducer;
import cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants;
import com.google.common.annotations.VisibleForTesting;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
/**
@ -40,48 +37,21 @@ import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
@Slf4j
public class OAuth2ClientServiceImpl implements OAuth2ClientService {
/**
* 客户端缓存
* key客户端编号 {@link OAuth2ClientDO#getClientId()} ()}
*
* 这里声明 volatile 修饰的原因是每次刷新时直接修改指向
*/
@Getter // 解决单测
@Setter // 解决单测
private volatile Map<String, OAuth2ClientDO> clientCache;
@Resource
private OAuth2ClientMapper oauth2ClientMapper;
@Resource
private OAuth2ClientProducer oauth2ClientProducer;
/**
* 初始化 {@link #clientCache} 缓存
*/
@Override
@PostConstruct
public void initLocalCache() {
// 第一步查询数据
List<OAuth2ClientDO> clients = oauth2ClientMapper.selectList();
log.info("[initLocalCache][缓存 OAuth2 客户端,数量为:{}]", clients.size());
// 第二步构建缓存
clientCache = convertMap(clients, OAuth2ClientDO::getClientId);
}
@Override
public Long createOAuth2Client(OAuth2ClientCreateReqVO createReqVO) {
validateClientIdExists(null, createReqVO.getClientId());
// 插入
OAuth2ClientDO oauth2Client = OAuth2ClientConvert.INSTANCE.convert(createReqVO);
oauth2ClientMapper.insert(oauth2Client);
// 发送刷新消息
oauth2ClientProducer.sendOAuth2ClientRefreshMessage();
return oauth2Client.getId();
}
@Override
@CacheEvict(cacheNames = RedisKeyConstants.OAUTH_CLIENT,
allEntries = true) // allEntries 清空所有缓存因为可能修改到 clientId 字段不好清理
public void updateOAuth2Client(OAuth2ClientUpdateReqVO updateReqVO) {
// 校验存在
validateOAuth2ClientExists(updateReqVO.getId());
@ -91,18 +61,16 @@ public class OAuth2ClientServiceImpl implements OAuth2ClientService {
// 更新
OAuth2ClientDO updateObj = OAuth2ClientConvert.INSTANCE.convert(updateReqVO);
oauth2ClientMapper.updateById(updateObj);
// 发送刷新消息
oauth2ClientProducer.sendOAuth2ClientRefreshMessage();
}
@Override
@CacheEvict(cacheNames = RedisKeyConstants.OAUTH_CLIENT,
allEntries = true) // allEntries 清空所有缓存因为 id 不是直接的缓存 key不好清理
public void deleteOAuth2Client(Long id) {
// 校验存在
validateOAuth2ClientExists(id);
// 删除
oauth2ClientMapper.deleteById(id);
// 发送刷新消息
oauth2ClientProducer.sendOAuth2ClientRefreshMessage();
}
private void validateOAuth2ClientExists(Long id) {
@ -131,16 +99,22 @@ public class OAuth2ClientServiceImpl implements OAuth2ClientService {
return oauth2ClientMapper.selectById(id);
}
@Override
@Cacheable(cacheNames = RedisKeyConstants.OAUTH_CLIENT, key = "#clientId")
public OAuth2ClientDO getOAuth2ClientFromCache(String clientId) {
return oauth2ClientMapper.selectByClientId(clientId);
}
@Override
public PageResult<OAuth2ClientDO> getOAuth2ClientPage(OAuth2ClientPageReqVO pageReqVO) {
return oauth2ClientMapper.selectPage(pageReqVO);
}
@Override
public OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret,
String authorizedGrantType, Collection<String> scopes, String redirectUri) {
public OAuth2ClientDO validOAuthClientFromCache(String clientId, String clientSecret, String authorizedGrantType,
Collection<String> scopes, String redirectUri) {
// 校验客户端存在且开启
OAuth2ClientDO client = clientCache.get(clientId);
OAuth2ClientDO client = getSelf().getOAuth2ClientFromCache(clientId);
if (client == null) {
throw exception(OAUTH2_CLIENT_NOT_EXISTS);
}
@ -167,4 +141,13 @@ public class OAuth2ClientServiceImpl implements OAuth2ClientService {
return client;
}
/**
* 获得自身的代理对象解决 AOP 生效问题
*
* @return 自己
*/
private OAuth2ClientServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}

View File

@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.system.service.oauth2;
import cn.hutool.core.map.MapUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
@ -9,14 +9,12 @@ import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2Cl
import cn.iocoder.yudao.module.system.controller.admin.oauth2.vo.client.OAuth2ClientUpdateReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2ClientDO;
import cn.iocoder.yudao.module.system.dal.mysql.oauth2.OAuth2ClientMapper;
import cn.iocoder.yudao.module.system.mq.producer.auth.OAuth2ClientProducer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.mockito.MockedStatic;
import org.springframework.context.annotation.Import;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.cloneIgnoreId;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals;
@ -24,7 +22,8 @@ import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServic
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.verify;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
/**
* {@link OAuth2ClientServiceImpl} 的单元测试类
@ -40,26 +39,6 @@ public class OAuth2ClientServiceImplTest extends BaseDbUnitTest {
@Resource
private OAuth2ClientMapper oauth2ClientMapper;
@MockBean
private OAuth2ClientProducer oauth2ClientProducer;
@Test
public void testInitLocalCache() {
// mock 数据
OAuth2ClientDO clientDO1 = randomPojo(OAuth2ClientDO.class);
oauth2ClientMapper.insert(clientDO1);
OAuth2ClientDO clientDO2 = randomPojo(OAuth2ClientDO.class);
oauth2ClientMapper.insert(clientDO2);
// 调用
oauth2ClientService.initLocalCache();
// 断言 clientCache 缓存
Map<String, OAuth2ClientDO> clientCache = oauth2ClientService.getClientCache();
assertEquals(2, clientCache.size());
assertPojoEquals(clientDO1, clientCache.get(clientDO1.getClientId()));
assertPojoEquals(clientDO2, clientCache.get(clientDO2.getClientId()));
}
@Test
public void testCreateOAuth2Client_success() {
// 准备参数
@ -73,7 +52,6 @@ public class OAuth2ClientServiceImplTest extends BaseDbUnitTest {
// 校验记录的属性是否正确
OAuth2ClientDO oAuth2Client = oauth2ClientMapper.selectById(oauth2ClientId);
assertPojoEquals(reqVO, oAuth2Client);
verify(oauth2ClientProducer).sendOAuth2ClientRefreshMessage();
}
@Test
@ -92,7 +70,6 @@ public class OAuth2ClientServiceImplTest extends BaseDbUnitTest {
// 校验是否更新正确
OAuth2ClientDO oAuth2Client = oauth2ClientMapper.selectById(reqVO.getId()); // 获取最新的
assertPojoEquals(reqVO, oAuth2Client);
verify(oauth2ClientProducer).sendOAuth2ClientRefreshMessage();
}
@Test
@ -116,7 +93,6 @@ public class OAuth2ClientServiceImplTest extends BaseDbUnitTest {
oauth2ClientService.deleteOAuth2Client(id);
// 校验数据不存在了
assertNull(oauth2ClientMapper.selectById(id));
verify(oauth2ClientProducer).sendOAuth2ClientRefreshMessage();
}
@Test
@ -166,6 +142,19 @@ public class OAuth2ClientServiceImplTest extends BaseDbUnitTest {
assertPojoEquals(clientDO, dbClientDO);
}
@Test
public void testGetOAuth2ClientFromCache() {
// mock 数据
OAuth2ClientDO clientDO = randomPojo(OAuth2ClientDO.class);
oauth2ClientMapper.insert(clientDO);
// 准备参数
String clientId = clientDO.getClientId();
// 调用并断言
OAuth2ClientDO dbClientDO = oauth2ClientService.getOAuth2ClientFromCache(clientId);
assertPojoEquals(clientDO, dbClientDO);
}
@Test
public void testGetOAuth2ClientPage() {
// mock 数据
@ -193,36 +182,39 @@ public class OAuth2ClientServiceImplTest extends BaseDbUnitTest {
@Test
public void testValidOAuthClientFromCache() {
// mock 方法
OAuth2ClientDO client = randomPojo(OAuth2ClientDO.class).setClientId("default")
.setStatus(CommonStatusEnum.ENABLE.getStatus());
OAuth2ClientDO client02 = randomPojo(OAuth2ClientDO.class).setClientId("disable")
.setStatus(CommonStatusEnum.DISABLE.getStatus());
Map<String, OAuth2ClientDO> clientCache = MapUtil.<String, OAuth2ClientDO>builder()
.put(client.getClientId(), client)
.put(client02.getClientId(), client02).build();
oauth2ClientService.setClientCache(clientCache);
try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(OAuth2ClientServiceImpl.class)))
.thenReturn(oauth2ClientService);
// 调用并断言
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache(randomString(),
null, null, null, null), OAUTH2_CLIENT_NOT_EXISTS);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("disable",
null, null, null, null), OAUTH2_CLIENT_DISABLE);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("default",
randomString(), null, null, null), OAUTH2_CLIENT_CLIENT_SECRET_ERROR);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("default",
null, randomString(), null, null), OAUTH2_CLIENT_AUTHORIZED_GRANT_TYPE_NOT_EXISTS);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("default",
null, null, Collections.singleton(randomString()), null), OAUTH2_CLIENT_SCOPE_OVER);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("default",
null, null, null, "test"), OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH, "test");
// 成功调用1参数完整
OAuth2ClientDO result = oauth2ClientService.validOAuthClientFromCache(client.getClientId(), client.getSecret(),
client.getAuthorizedGrantTypes().get(0), client.getScopes(), client.getRedirectUris().get(0));
assertPojoEquals(client, result);
// 成功调用2只有 clientId 参数
result = oauth2ClientService.validOAuthClientFromCache(client.getClientId());
assertPojoEquals(client, result);
// mock 方法
OAuth2ClientDO client = randomPojo(OAuth2ClientDO.class).setClientId("default")
.setStatus(CommonStatusEnum.ENABLE.getStatus());
oauth2ClientMapper.insert(client);
OAuth2ClientDO client02 = randomPojo(OAuth2ClientDO.class).setClientId("disable")
.setStatus(CommonStatusEnum.DISABLE.getStatus());
oauth2ClientMapper.insert(client02);
// 调用并断言
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache(randomString(),
null, null, null, null), OAUTH2_CLIENT_NOT_EXISTS);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("disable",
null, null, null, null), OAUTH2_CLIENT_DISABLE);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("default",
randomString(), null, null, null), OAUTH2_CLIENT_CLIENT_SECRET_ERROR);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("default",
null, randomString(), null, null), OAUTH2_CLIENT_AUTHORIZED_GRANT_TYPE_NOT_EXISTS);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("default",
null, null, Collections.singleton(randomString()), null), OAUTH2_CLIENT_SCOPE_OVER);
assertServiceException(() -> oauth2ClientService.validOAuthClientFromCache("default",
null, null, null, "test"), OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH, "test");
// 成功调用1参数完整
OAuth2ClientDO result = oauth2ClientService.validOAuthClientFromCache(client.getClientId(), client.getSecret(),
client.getAuthorizedGrantTypes().get(0), client.getScopes(), client.getRedirectUris().get(0));
assertPojoEquals(client, result);
// 成功调用2只有 clientId 参数
result = oauth2ClientService.validOAuthClientFromCache(client.getClientId());
assertPojoEquals(client, result);
}
}
}

View File

@ -191,6 +191,7 @@ yudao:
- tmp_report_data_income
ignore-caches:
- permission_menu_ids
- oauth_client
sms-code: # 短信验证码相关的配置项
expire-times: 10m
send-frequency: 1m