|
|
@@ -13,6 +13,7 @@ import org.springframework.data.redis.connection.stream.RecordId;
|
|
|
import org.springframework.data.redis.connection.stream.StreamOffset;
|
|
|
import org.springframework.data.redis.connection.stream.StreamReadOptions;
|
|
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
|
+import org.springframework.data.redis.core.script.DefaultRedisScript;
|
|
|
import org.slf4j.Logger;
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
|
@@ -33,6 +34,8 @@ public class HonorHotStore {
|
|
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(HonorHotStore.class);
|
|
|
private static final Duration IMPRESSION_BID_TTL = Duration.ofHours(3);
|
|
|
+ private static final Duration DEFAULT_CONVERSION_SEEN_TTL = Duration.ofDays(7);
|
|
|
+ private static final Duration DEFAULT_DEDUCTION_COUNTER_TTL = Duration.ofDays(7);
|
|
|
|
|
|
private final StringRedisTemplate redis;
|
|
|
private final ObjectMapper objectMapper;
|
|
|
@@ -41,6 +44,7 @@ public class HonorHotStore {
|
|
|
private final String stream;
|
|
|
private final Duration bidTtl;
|
|
|
private final long streamMaxLen;
|
|
|
+ private final DefaultRedisScript<List> deductionDecisionScript;
|
|
|
private final Set<String> initializedGroups = ConcurrentHashMap.newKeySet();
|
|
|
|
|
|
public HonorHotStore(StringRedisTemplate redis, ObjectMapper objectMapper,
|
|
|
@@ -53,6 +57,7 @@ public class HonorHotStore {
|
|
|
this.stream = stream;
|
|
|
this.bidTtl = bidTtl;
|
|
|
this.streamMaxLen = streamMaxLen;
|
|
|
+ this.deductionDecisionScript = buildDeductionDecisionScript();
|
|
|
}
|
|
|
|
|
|
public void recordBid(HonorBidRecord record) {
|
|
|
@@ -168,6 +173,35 @@ public class HonorHotStore {
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
+ public boolean markConversionSeen(String dedupeKey) {
|
|
|
+ if (dedupeKey == null || dedupeKey.isBlank()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ Boolean created = redis.opsForValue().setIfAbsent(
|
|
|
+ conversionSeenKey(dedupeKey),
|
|
|
+ "1",
|
|
|
+ DEFAULT_CONVERSION_SEEN_TTL
|
|
|
+ );
|
|
|
+ return Boolean.TRUE.equals(created);
|
|
|
+ }
|
|
|
+
|
|
|
+ public DeductionDecision evaluateDeduction(String accountId, int act, String date, int deductionRate) {
|
|
|
+ String key = deductionCounterKey(accountId, act, date);
|
|
|
+ List result = redis.execute(
|
|
|
+ deductionDecisionScript,
|
|
|
+ Collections.singletonList(key),
|
|
|
+ String.valueOf(Math.max(deductionRate, 0)),
|
|
|
+ String.valueOf(DEFAULT_DEDUCTION_COUNTER_TTL.getSeconds())
|
|
|
+ );
|
|
|
+ if (result == null || result.size() < 3) {
|
|
|
+ throw new RuntimeException("evaluate honor deduction failed");
|
|
|
+ }
|
|
|
+ long total = toLong(result.get(0));
|
|
|
+ long deducted = toLong(result.get(1));
|
|
|
+ boolean shouldDeduct = toLong(result.get(2)) == 1L;
|
|
|
+ return new DeductionDecision(total, deducted, shouldDeduct);
|
|
|
+ }
|
|
|
+
|
|
|
public void ensureGroup(String group) {
|
|
|
if (group == null || group.isBlank()) return;
|
|
|
if (initializedGroups.contains(group)) return;
|
|
|
@@ -265,6 +299,48 @@ public class HonorHotStore {
|
|
|
|
|
|
private String bidKey(String qk) { return prefix + "bid:" + qk; }
|
|
|
private String mediaTraceKey(String traceId) { return prefix + "media:honor:" + traceId; }
|
|
|
+ private String conversionSeenKey(String dedupeKey) { return prefix + "conv:seen:honor:" + dedupeKey; }
|
|
|
+
|
|
|
+ private String deductionCounterKey(String accountId, int act, String date) {
|
|
|
+ return String.format("%sdeduct:state:honor:acct:%s:act:%d:date:%s",
|
|
|
+ prefix,
|
|
|
+ sanitizePart(accountId, "unknown"),
|
|
|
+ act,
|
|
|
+ sanitizePart(date, "unknown"));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String sanitizePart(String value, String fallback) {
|
|
|
+ if (value == null || value.isBlank()) return fallback;
|
|
|
+ return value.replace(':', '_').trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static long toLong(Object value) {
|
|
|
+ if (value instanceof Number number) return number.longValue();
|
|
|
+ return Long.parseLong(String.valueOf(value));
|
|
|
+ }
|
|
|
+
|
|
|
+ private DefaultRedisScript<List> buildDeductionDecisionScript() {
|
|
|
+ DefaultRedisScript<List> script = new DefaultRedisScript<>();
|
|
|
+ script.setResultType(List.class);
|
|
|
+ script.setScriptText("""
|
|
|
+ local key = KEYS[1]
|
|
|
+ local rate = tonumber(ARGV[1]) or 0
|
|
|
+ local ttl = tonumber(ARGV[2]) or 0
|
|
|
+ local total = redis.call('HINCRBY', key, 'total', 1)
|
|
|
+ local deducted = tonumber(redis.call('HGET', key, 'deducted') or '0')
|
|
|
+ local target = math.floor((total * rate) / 100)
|
|
|
+ local should_deduct = 0
|
|
|
+ if deducted < target then
|
|
|
+ deducted = redis.call('HINCRBY', key, 'deducted', 1)
|
|
|
+ should_deduct = 1
|
|
|
+ end
|
|
|
+ if ttl > 0 then
|
|
|
+ redis.call('EXPIRE', key, ttl)
|
|
|
+ end
|
|
|
+ return { total, deducted, should_deduct }
|
|
|
+ """);
|
|
|
+ return script;
|
|
|
+ }
|
|
|
|
|
|
private HonorBidRecord parseBidRecord(String payload) {
|
|
|
try {
|
|
|
@@ -315,4 +391,20 @@ public class HonorHotStore {
|
|
|
}
|
|
|
return msg == null ? "" : msg;
|
|
|
}
|
|
|
+
|
|
|
+ public static class DeductionDecision {
|
|
|
+ private final long totalSeen;
|
|
|
+ private final long totalDeducted;
|
|
|
+ private final boolean shouldDeduct;
|
|
|
+
|
|
|
+ public DeductionDecision(long totalSeen, long totalDeducted, boolean shouldDeduct) {
|
|
|
+ this.totalSeen = totalSeen;
|
|
|
+ this.totalDeducted = totalDeducted;
|
|
|
+ this.shouldDeduct = shouldDeduct;
|
|
|
+ }
|
|
|
+
|
|
|
+ public long getTotalSeen() { return totalSeen; }
|
|
|
+ public long getTotalDeducted() { return totalDeducted; }
|
|
|
+ public boolean isShouldDeduct() { return shouldDeduct; }
|
|
|
+ }
|
|
|
}
|