加了 @Idempotent 还是重复扣款了 3 笔:扒完 1279 行幂等 Starter,我挖出 5 个隐蔽的坑

系列第 4 篇。上一篇我们聊了多租户和数据权限拦截器怎么共存,这篇兑现预告,来拆 forge-starter-idempotent

全文代码均来自真实仓库,行数、参数、默认值都是我一个个核过的。


一、先说那个让我熬夜的 Bug

去年给一个客户做支付回调,接口长这样:

java 复制代码
@PostMapping("/pay/callback")
@Idempotent(key = "'pay:' + #request.orderNo", strategy = IdempotentStrategy.STRICT)
public PayResult callback(@RequestBody PayCallbackRequest request) {
    return payService.handleCallback(request);
}

看着挺标准对吧?压测的时候 500 并发打过去,同一个订单号扣了 3 次款

我当时第一反应是:Redis 挂了?Redisson 锁没生效?SpEL 写错了?

都不是。

查到最后我发现,问题出在一个我从来没想过的地方------注解上的 expire = 600 这个参数,在 STRICT 策略里压根没被用过

下面从头拆。


二、模块全景

整个 starter 只有 1279 行主代码 + 824 行测试,结构很清晰:

csharp 复制代码
forge-starter-idempotent/
├── annotation/
│   └── Idempotent.java               # 幂等注解(10 个可配属性)
├── aop/
│   └── IdempotentAspect.java         # 唯一入口切面
├── strategy/
│   ├── IdempotentStrategyHandler.java    # 策略接口(单方法)
│   ├── StrictStrategyHandler.java        # 策略①:严格拒绝
│   ├── ReturnCacheStrategyHandler.java   # 策略②:返回缓存结果
│   └── TokenRequiredStrategyHandler.java # 策略③:Token 校验
├── generator/
│   ├── IdempotentKeyGenerator.java       # 键生成接口
│   └── DefaultIdempotentKeyGenerator.java# 默认实现(SpEL + SHA-256)
├── lock/
│   ├── LockManager.java
│   └── RedissonLockManager.java      # Redisson 分布式锁
├── service/
│   ├── TokenService.java / RedisTokenService.java
│   ├── ResultCacheService.java / RedisResultCacheService.java
│   └── IdempotentStorageService.java
├── util/SpelUtil.java
└── config/IdempotentAutoConfiguration.java

调用链路一句话说清楚:

kotlin 复制代码
Controller 方法
    ↓  @Around("@annotation(idempotent)")
IdempotentAspect.around()
    ↓  keyGenerator.generate()  生成幂等键
    ↓  strategyHandlers.get(strategy)
┌───┴──────────────┬────────────────────┐
│ STRICT           │ RETURN_CACHE       │ TOKEN_REQUIRED
│ tryLock 抢锁     │ 先查缓存 → 抢锁     │ 校验 Token → 委派
│ 失败直接抛异常   │ 失败 sleep 100ms   │   ↓
│                  │ 再查缓存 → 抛异常   │ returnCache 处理器
└──────────────────┴────────────────────┘
    ↓
joinPoint.proceed()  执行业务
    ↓
成功:可选缓存结果 / 可选删键
失败:无条件 unlock

三、三种策略到底差在哪

先看注解定义,@Idempotent 有 10 个属性,日常真正会调的就这几个:

java 复制代码
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    String prefix() default IdempotentConstant.DEFAULT_PREFIX;  // "idempotent:"
    int expire() default IdempotentConstant.DEFAULT_EXPIRE;     // 600 秒
    String key() default "";                                     // SpEL 表达式
    String message() default IdempotentConstant.DEFAULT_MESSAGE; // "请勿重复提交"
    boolean deleteKeyAfterSuccess() default false;
    IdempotentStrategy strategy() default IdempotentStrategy.RETURN_CACHE;
    int cacheExpire() default IdempotentConstant.CACHE_EXPIRE_DEFAULT; // 3600 秒
    boolean cacheResult() default true;
    boolean enableMetrics() default true;
}

三种策略的行为差异,我整理成表:

STRICT RETURN_CACHE(默认) TOKEN_REQUIRED
重复请求 直接抛 IdempotentException 返回上次缓存的结果 先校验 Token,再走 RETURN_CACHE
首次请求 抢锁 → 执行 → 靠租期自然过期 抢锁 → 执行 → 写结果缓存 校验+消费 Token → 委派
并发请求 抢不到锁立刻失败 sleep 100ms 后重读缓存,读不到抛"并发冲突" 同 RETURN_CACHE
业务失败 unlock() 释放锁,允许重试 unlock() 释放锁,允许重试 同 RETURN_CACHE
适用场景 支付、转账等"宁可报错也不能重复" 表单提交、创建订单 前端显式申请 Token 的场景

注意 TOKEN_REQUIRED 不是独立实现,它是个装饰器:

java 复制代码
public class TokenRequiredStrategyHandler implements IdempotentStrategyHandler {
    private final TokenService tokenService;
    private final TokenProperties tokenProperties;
    private final IdempotentStrategyHandler delegateHandler;   // ← 委派给 returnCache

    @Override
    public Object handle(ProceedingJoinPoint joinPoint, Idempotent annotation, String idempotentKey) throws Throwable {
        String token = extractToken();
        String prefix = annotation.prefix();

        if (!tokenService.validateToken(token, prefix)) {
            throw new TokenInvalidException("Token无效或已过期");
        }
        tokenService.consumeToken(token, prefix);
        return delegateHandler.handle(joinPoint, annotation, idempotentKey);
    }
}

这个设计是对的------Token 只负责"防重放的门票",真正的幂等语义交给 RETURN_CACHE。


四、键生成:一段被低估的代码

这段是整个模块里我最欣赏的部分,很多自研幂等组件都漏了:

java 复制代码
@Override
public String generate(ProceedingJoinPoint joinPoint, String prefix, String key) {
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    Object[] args = joinPoint.getArgs();
    String[] paramNames = PARAMETER_NAME_DISCOVERER.getParameterNames(method);

    String keyValue;
    if (key != null && !key.isEmpty()) {
        Object spelResult = SpelUtil.parse(key, args, paramNames);
        keyValue = spelResult != null ? spelResult.toString() : "";
    } else {
        String methodSign = method.getDeclaringClass().getName() + ":" + method.getName();
        keyValue = sha256(methodSign + ":" + serializeArguments(args));
    }
    return prefix + keyValue;
}

private String serializeArguments(Object[] args) {
    try {
        JsonNode tree = stableObjectMapper.valueToTree(args == null ? new Object[0] : args);
        return stableObjectMapper.writeValueAsString(canonicalize(tree));
    } catch (IllegalArgumentException | JsonProcessingException e) {
        throw new IllegalStateException("无法稳定序列化幂等参数,请通过 SpEL 显式指定幂等 key", e);
    }
}

private JsonNode canonicalize(JsonNode node) {
    if (node.isObject()) {
        Map<String, JsonNode> fields = new TreeMap<>();          // ← 关键:排序
        node.properties().forEach(entry -> fields.put(entry.getKey(), entry.getValue()));
        ObjectNode result = stableObjectMapper.createObjectNode();
        fields.forEach((name, value) -> result.set(name, canonicalize(value)));
        return result;
    }
    if (node.isArray()) {
        ArrayNode result = stableObjectMapper.createArrayNode();
        node.forEach(value -> result.add(canonicalize(value)));
        return result;
    }
    return node;
}

canonicalizeTreeMap 递归重排了 JSON 的所有字段。

为什么要这么干?因为 Jackson 序列化对象的字段顺序取决于类里 getter 的声明顺序------对同一个类来说顺序是固定的,但对同一个 DTO 的两个实例 ,如果一个是 Jackson 反序列化出来的、一个是 new 出来的,理论上顺序一致。真正会翻车的是 Map 参数:HashMap 的遍历顺序跟 hash 有关,同样的内容可能有不同的序列化结果。

排序之后,{"a":1,"b":2}{"b":2,"a":1} 会得到同一个 SHA-256,幂等键才稳定。

另外注意异常抛出的信息:"无法稳定序列化幂等参数,请通过 SpEL 显式指定幂等 key" ------ 没写 SpEL 又序列化失败时,它选择 fail-fast 抛异常,而不是随便生成一个 key。这个取舍是对的。


五、5 个隐蔽的坑

坑 1:SpEL 写错不会报错,所有请求会共用一把锁

这是最容易中招的一个。看 SpelUtil

java 复制代码
public static Object parse(String expression, Object[] args, String[] paramNames) {
    try {
        EvaluationContext context = new StandardEvaluationContext();
        if (args != null && paramNames != null) {
            for (int i = 0; i < args.length; i++) {
                context.setVariable(paramNames[i], args[i]);
            }
        }
        Expression exp = PARSER.parseExpression(expression);
        return exp.getValue(context);
    } catch (Exception e) {
        log.warn("SpEL表达式解析失败: {}", expression, e);
        return null;      // ← 吞掉异常,返回 null
    }
}

再看回 generate

java 复制代码
Object spelResult = SpelUtil.parse(key, args, paramNames);
keyValue = spelResult != null ? spelResult.toString() : "";   // ← null → ""

解析失败 → null"" → 最终幂等键 = prefix = "idempotent:"

后果:这个接口的所有请求,不管参数是什么,都挤在同一把锁上。 表现就是"接口莫名其妙串行了,QPS 上不去",或者"用户 A 提交完了,用户 B 提交报'请勿重复提交'"。

而且日志级别是 warn,生产环境日志量大时根本注意不到。

常见的 SpEL 翻车写法:

错误写法 原因
key = "#orderId" 没有前缀,也没加引号;应该用 "'order:' + #orderId"
key = "'order:' + #order.orderNo" 参数名被编译成 arg0(没开 -parameters
key = "#request.getOrderNo()" 应该写 #request.orderNo,SpEL 走 getter

解法 :上线前用单元测试把 key 打出来看一眼。项目里 DefaultIdempotentKeyGeneratorTest 就是干这个的,别偷懒。


坑 2:STRICT 策略下 expire 是个死参数,锁租期只有 5 秒

回到开头那个扣款 3 次的问题。看 StrictStrategyHandler 的完整代码:

java 复制代码
@Override
public Object handle(ProceedingJoinPoint joinPoint, Idempotent annotation, String idempotentKey) throws Throwable {
    int expire = annotation.expire();        // ← 取出来了

    if (!lockManager.tryLock(idempotentKey, lockProperties.getWaitTime(), lockProperties.getLeaseTime())) {
        //                                    ↑ waitTime  ↑ leaseTime,都没用 expire
        log.warn("严格模式: 拒绝重复请求, key={}", idempotentKey);
        throw new IdempotentException(annotation.message());
    }

    try {
        Object result = joinPoint.proceed();
        if (annotation.deleteKeyAfterSuccess()) {
            lockManager.unlock(idempotentKey);
        }
        return result;
    } catch (Throwable e) {
        lockManager.unlock(idempotentKey);
        throw e;
    }
}

int expire = annotation.expire(); 声明之后再也没被用过。真正的锁租期来自:

java 复制代码
@ConfigurationProperties(prefix = "forge.idempotent.lock")
public class LockProperties {
    private boolean enabled = true;
    private long waitTime = 3000;    // 抢锁最多等 3 秒
    private long leaseTime = 5000;   // 锁 5 秒后自动释放
}

这带来两个问题:

问题 A:业务执行超过 5 秒,幂等直接失效。

支付回调要调第三方、要发消息、要更新好几张表,5 秒很容易超。锁一到点自动释放,第二个请求顺利拿到锁,重复扣款就发生了------这就是我那 3 笔重复扣款的全部原因

问题 B:Redisson 传了 leaseTime 就不会启动看门狗。

java 复制代码
// RedissonLockManager
boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);

Redisson 的 watchdog 续期机制只在 leaseTime = -1 时生效。一旦你显式传了 5000ms,它就是个死租期,没有任何续期。

解法(三选一)

java 复制代码
// 方案一:调大 leaseTime(简单粗暴,但要估准业务耗时上限)
forge:
  idempotent:
    lock:
      lease-time: 30000

// 方案二:改用 RETURN_CACHE + Redis 幂等记录,让"已执行"标记和"互斥"分离
@Idempotent(key = "'pay:' + #request.orderNo", strategy = IdempotentStrategy.RETURN_CACHE)

// 方案三(推荐):别用锁当去重标记。锁只做并发互斥,去重另用 Redis SETNX + 业务状态机

核心认知:分布式锁 ≠ 幂等标记。 锁的语义是"同一时刻只能有一个",幂等的语义是"同一个请求只能成功一次"。用锁兼职做去重,就得接受"锁过期 = 幂等失效"。


坑 3:RETURN_CACHE 反序列化出来的是 LinkedHashMap,不是你的返回值类型

先看缓存怎么写进去的:

java 复制代码
// RedisResultCacheService
public void cacheResult(String key, Object result, int expireSeconds) {
    String resultJson = objectMapper.writeValueAsString(result);
    Map<String, String> cacheData = new HashMap<>();
    cacheData.put("result", resultJson);
    cacheData.put("status", IdempotentResult.STATUS_SUCCESS);
    redisTemplate.opsForHash().putAll(cacheKey, cacheData);
    redisTemplate.expire(cacheKey, expireSeconds, TimeUnit.SECONDS);
}

再看怎么读出来:

java 复制代码
String resultJson = (String) entries.get("result");
if (resultJson != null && !resultJson.isEmpty()) {
    result.setResult(objectMapper.readValue(resultJson, Object.class));   // ← 问题在这
}

readValue(json, Object.class) 的结果类型是 LinkedHashMap ,不是 Order、不是 PayResult

然后切面直接把它 return 了:

java 复制代码
// ReturnCacheStrategyHandler
return cachedResult.getResult();

后果 :方法签名声明返回 Order,实际返回 LinkedHashMap。JVM 在调用点插入的 checkcast 会抛 ClassCastException,而且这个异常发生在调用方,不是幂等组件里------排查时你会一脸懵:为什么我的订单服务报了个类型转换错误?

更阴的是:首次请求正常,重复请求才炸。开发环境你手动点两下才能复现,自动化压测如果每次都用新 key 也测不出来。

解法IdempotentResult.resultObject 存是有道理的(要兼容各种返回类型),但读的时候必须带上目标类型:

java 复制代码
// 改进:把返回类型也存进 Redis
Class<?> returnType = ((MethodSignature) joinPoint.getSignature()).getMethod().getReturnType();
cacheData.put("returnType", returnType.getName());

// 读取时按真实类型还原
Class<?> type = Class.forName((String) entries.get("returnType"));
result.setResult(objectMapper.readValue(resultJson, type));

或者更省事:只缓存 RespInfo 这类统一包装类型,反序列化到具体类型。


坑 4:TOKEN_REQUIRED 模式下 Token 一定取不到

这段是我在拆源码时意外发现的,它让整个 Token 模式处于不可用状态 。看 extractToken

java 复制代码
private String extractToken() {
    org.aspectj.lang.ProceedingJoinPoint jp = null;
    try {
        org.springframework.web.context.request.RequestContextHolder currentRequest =
            (org.springframework.web.context.request.RequestContextHolder)                    // ← 强转目标类型错了
            org.springframework.web.context.request.RequestContextHolder.getRequestAttributes(); // ← 返回的是 RequestAttributes

        if (currentRequest != null) {
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            HttpServletRequest request = attributes.getRequest();
            return request.getHeader(tokenProperties.getHeader());
        }
    } catch (Exception e) {
        log.warn("提取Token失败: {}", e.getMessage());     // ← 异常被吞
    }
    return null;
}

getRequestAttributes() 的返回类型是 RequestAttributes,这里却强转成了 RequestContextHolder(一个工具类,跟 RequestAttributes 没有继承关系)。

只要有 HTTP 请求,这行必然抛 ClassCastException ,被 catch 吞掉后 return null。然后:

java 复制代码
if (!tokenService.validateToken(token, prefix)) {   // token = null
    throw new TokenInvalidException("Token无效或已过期");
}

// RedisTokenService.validateToken
if (token == null || token.isEmpty()) {
    log.warn("Token为空");
    return false;
}

结果:任何带 TOKEN_REQUIRED 的接口都会 100% 抛 TokenInvalidException ,即使你请求头里规规矩矩带了 X-Idempotent-Token

而且日志只有一行 提取Token失败: ...,是 warn 级别。

正确写法(去掉那个莫名其妙的强转就行):

java 复制代码
private String extractToken() {
    RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
    if (attrs instanceof ServletRequestAttributes sra) {
        return sra.getRequest().getHeader(tokenProperties.getHeader());
    }
    log.warn("非 Servlet 请求上下文,无法提取幂等 Token");
    return null;
}

顺带说一句,catch (Exception e) 吞异常这件事本身也要警惕:它把"代码写错了"伪装成了"Token 没传",两者处理方式完全不同。


坑 5:Token 的校验和消费不是原子操作,高并发下会双花

即使修好了坑 4,Token 模式还有一个并发问题:

java 复制代码
// 第一步:校验
public boolean validateToken(String token, String prefix) {
    Boolean exists = redisTemplate.hasKey(key);
    if (!Boolean.TRUE.equals(exists)) return false;

    String status = (String) redisTemplate.opsForHash().get(key, "status");
    if (TOKEN_STATUS_CONSUMED.equals(status)) return false;
    return true;
}

// 第二步:消费
public void consumeToken(String token, String prefix) {
    redisTemplate.opsForHash().put(key, "status", TOKEN_STATUS_CONSUMED);
    redisTemplate.expire(key, 60, TimeUnit.SECONDS);
}

hasKey + get status + put status三条独立的 Redis 命令 ,中间没有任何原子性保证。两个并发请求同时执行到第 6 行,都会读到 status = UNUSED,都会通过校验,都会往下走。

解法:用 Lua 脚本或者 Redis 原子命令把"检查+标记"合成一步:

lua 复制代码
-- check_and_consume.lua
local key = KEYS[1]
local status = redis.call('HGET', key, 'status')
if status == false then
    return 0          -- Token 不存在
end
if status == 'CONSUMED' then
    return -1         -- 已消费
end
redis.call('HSET', key, 'status', 'CONSUMED')
redis.call('EXPIRE', key, 60)
return 1              -- 消费成功
java 复制代码
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/check_and_consume.lua")));
script.setResultType(Long.class);
Long result = redisTemplate.execute(script, List.of(key));

顺带一提,consumeToken 里把过期时间从配置的 300 秒改成 60 秒,是为了让"已消费"痕迹多保留一会儿,防止 Token 过期后被重放------这个细节是对的。


补充:类上的 @Idempotent 不生效

注解声明了 ElementType.TYPE

java 复制代码
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Idempotent {

但切面的切点只有方法级:

java 复制代码
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {

写在类上的 @Idempotent 会被静默忽略,不报错、不打日志、就是不起作用。

这个坑我在上一篇拆 @IgnoreTenant 时也遇到过,当时项目里的解法是 @annotation + @within 双切点匹配。幂等这里如果想支持类级,得改成:

java 复制代码
@Around("@annotation(idempotent) || (@within(idempotent) && execution(public * *(..)))")

六、可带走的 6 条诀窍

  1. 锁不是幂等标记。 分布式锁管"并发互斥",幂等管"只成功一次"。让锁兼职做去重,就要接受"锁过期即失效"。租期一定要大于业务最大耗时,或者干脆把两者拆开。

  2. SpEL 一定要写单元测试。 解析失败是静默降级成空字符串,表现为"全站串行",比直接报错难查一百倍。

  3. 缓存结果必须连带类型信息。 readValue(json, Object.class) 得到的永远是 LinkedHashMap,返回给声明了具体类型的方法必然 ClassCastException,而且在调用方炸。

  4. 别用 catch (Exception) 吞掉强转和 ClassCast。 它会把"代码 bug"伪装成"业务校验失败",日志里只剩一行 warn。

  5. "检查 + 标记"必须原子。 hasKey + get + put 三步走,在高并发下等于没有幂等。Lua 脚本或者 SETNX 安排上。

  6. 注解的 @Target 要和切点表达式对齐。 声明支持 TYPE 就得写 @within,否则就是给人挖坑。


七、两个值得单独说的设计

失败了一定要放锁

三个策略的 catch 分支都做了 lockManager.unlock()

java 复制代码
} catch (Throwable e) {
    lockManager.unlock(idempotentKey);
    throw e;
}

这意味着业务失败后同一个 key 可以立即重试 。这是个刻意的取舍:网络抖动、数据库超时这类瞬时失败,用户点第二次应该能成功。如果你的场景要求"失败了也不能重试"(比如某些金融场景),就要把 deleteKeyAfterSuccess 反过来用,或者自己改策略。

找不到策略处理器时是放行的

java 复制代码
IdempotentStrategyHandler handler = strategyHandlers.get(strategy);
if (handler == null) {
    log.warn("未找到策略处理器: strategy={}", strategy);
    return joinPoint.proceed();     // ← fail-open
}

幂等组件找不到处理器时选择放行 而不是拒绝。这是"日志/审计类组件"的标准做法------旁路组件永远不应该阻断主流程。同样的思路在日志 starter、埋点 SDK 里都能看到。

反过来说,如果你的业务要求"幂等失效时必须拒绝请求",这里就得改成抛异常。


八、总结

幂等看起来是个"加个注解就完事"的小功能,但真要落地,你会发现它牵扯一堆决策:

决策点 这个 starter 的选择
去重标记放哪 Redis 分布式锁(STRICT)/ Redis 结果缓存(RETURN_CACHE)
并发冲突怎么办 STRICT 立即失败;RETURN_CACHE sleep 100ms 重试一次
业务失败后能否重试 能(无条件 unlock)
结果怎么缓存 JSON 存 Hash,字段含 requestId / result / status / executeTime
组件异常时的默认行为 fail-open,放行
key 怎么生成 SpEL 优先,否则 SHA-256(方法签名 + 规范化参数 JSON)

1279 行代码,29 个类,把这套东西讲清楚了。我扒完的收获是:幂等最难的不是实现,是边界情况的取舍------锁过期了怎么办、业务失败了怎么办、组件自己挂了怎么办、缓存的类型对不上怎么办。

这些取舍没有标准答案,但你必须明确地选一个,而不是让它静默地错下去。


这篇如果对你有启发,点个赞吧 🙏

extractToken 那个强转 bug 我赌很多人写过类似的------把 RequestContextHolder.getRequestAttributes() 的返回类型记混,然后被一个 catch (Exception) 完美隐藏。评论区聊聊你踩过的最阴的幂等坑。

下一篇我们拆 forge-starter-log(1383 行)------操作日志切面为什么拦的是所有 Controller 而不是 @OperationLog 标注的方法,以及那个只有 2 个核心线程的日志线程池是怎么把接口拖慢的。


项目地址

系列回顾

  1. 数据权限拦截器:681 行是怎么改写 SQL 的
  2. 多租户 tenant Starter:数据源级 + 行级双隔离
  3. 多租户 × 数据权限共存:拦截器注册顺序的 4 个坑
  4. 幂等 Starter:1279 行里的 5 个隐蔽坑(本文)

标签#Java #Spring Boot #Redis #幂等 #源码拆解 #Redisson #架构设计

相关推荐
步行cgn1 小时前
Spring 注入内部 Bean 详解
java·spring·rpc
user_admin_god1 小时前
第 05 篇:结构化输出 —— 让模型稳定返回 JSON
java·人工智能·spring boot·语言模型
土司大王1 小时前
LeetCode hot100——394.字符串解码:Java 双栈模拟
java·算法·leetcode
邪修king1 小时前
Re:Linux系统篇(二十五):文件系统(一):磁盘硬件底层原理:从物理结构到 CHS/LBA 寻址,搞懂硬盘数据的定位逻辑
java·linux·运维·gpt
驭渊的小故事1 小时前
Spring Boot 注解详解01
java·前端·spring boot
泡海椒1 小时前
jquick-pdf 超详细入门教程:Java 轻量级 HTML 模板生成 PDF 工具
java·开发语言
蛋先生DX2 小时前
Java和Go都拍胸脯说"内存我包了",可它们到底是怎么下手的?
java·go·编程语言
pe7er2 小时前
引子
前端·后端·架构
SimonKing2 小时前
阅后即焚的加密便签:Cryptgeon,你口袋里的秘密信使
java·后端·程序员