瀏覽代碼

油管香港美国减半时长

zoujiajian 1 月之前
父節點
當前提交
c29ee856b4

+ 24 - 0
netflix-dao/src/main/java/com/cyksj/mapper/TicketDurationRepairMapper.java

@@ -0,0 +1,24 @@
+package com.cyksj.mapper;
+
+import com.cyksj.model.dto.TicketDurationRepairOrder;
+import com.cyksj.model.dto.TicketDurationRepairRow;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+public interface TicketDurationRepairMapper {
+
+    List<TicketDurationRepairRow> selectPage(@Param("afterId") Long afterId,
+                                            @Param("limit") Integer limit,
+                                            @Param("userId") Long userId);
+
+    TicketDurationRepairRow selectForUpdate(@Param("relationId") Long relationId);
+
+    List<TicketDurationRepairOrder> selectOrders(@Param("relationIds") List<Long> relationIds);
+
+    List<Long> selectAdjustedRelationIds(@Param("relationIds") List<Long> relationIds);
+
+    int updateExpiry(@Param("row") TicketDurationRepairRow row,
+                     @Param("expiryTime") Date expiryTime);
+}

+ 19 - 0
netflix-dao/src/main/java/com/cyksj/model/dto/TicketDurationRepairOrder.java

@@ -0,0 +1,19 @@
+package com.cyksj.model.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * An order associated with the ticket's current owner.
+ */
+@Getter
+@Setter
+public class TicketDurationRepairOrder {
+
+    private Long relationId;
+    private Long orderId;
+    private Long skuId;
+    private Integer orderType;
+    private Integer num;
+    private Boolean slot;
+}

+ 30 - 0
netflix-dao/src/main/java/com/cyksj/model/dto/TicketDurationRepairRow.java

@@ -0,0 +1,30 @@
+package com.cyksj.model.dto;
+
+import com.cyksj.model.entity.GroupsRelation;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Date;
+
+/**
+ * Ticket fields needed to inspect and conditionally repair its duration.
+ */
+@Getter
+@Setter
+public class TicketDurationRepairRow {
+
+    private Long relationId;
+    private Long groupsId;
+    private Long skuId;
+    private Long userId;
+    private GroupsRelation.Status status;
+    private Date startTime;
+    private Date expiryTime;
+    private Date updateTime;
+    private Boolean isFrozen;
+    private Boolean isRecover;
+    private Boolean isVip;
+    private Integer reservedDays;
+    private Integer compensationDays;
+    private Integer aqType;
+}

+ 42 - 0
netflix-dao/src/main/java/com/cyksj/model/request/TicketDurationRepairReq.java

@@ -0,0 +1,42 @@
+package com.cyksj.model.request;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Positive;
+import javax.validation.constraints.Size;
+import java.util.List;
+
+@Getter
+@Setter
+public class TicketDurationRepairReq {
+    /** 本批扣减记录的备注;不填或纯空白时使用默认修复说明。 */
+    private String remark;
+
+    @Valid
+    @NotEmpty(message = "请选择预览中的待修复车票")
+    @Size(max = 200, message = "每次最多修复200张车票")
+    private List<@NotNull Snapshot> items;
+
+    /** 从预览原样回传;时间字段均为毫秒时间戳。 */
+    @Getter
+    @Setter
+    public static class Snapshot {
+        @NotNull @Positive
+        private Long relationId;
+        @NotNull @Positive
+        private Long groupsId;
+        @NotNull @Positive
+        private Long skuId;
+        @NotNull @Positive
+        private Long userId;
+        @NotNull
+        private Long startTime;
+        @NotNull
+        private Long expiryTime;
+        private Long updateTime;
+    }
+}

+ 24 - 0
netflix-dao/src/main/java/com/cyksj/model/response/TicketDurationRepairItemResp.java

@@ -0,0 +1,24 @@
+package com.cyksj.model.response;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class TicketDurationRepairItemResp {
+    private Long relationId;
+    private Long groupsId;
+    private Long skuId;
+    private Long userId;
+    /** 所有时间为毫秒时间戳;expiryTime 为修改前的到期时间。 */
+    private Long startTime;
+    private Long expiryTime;
+    private Long updateTime;
+    private Long newExpiryTime;
+    /** candidate 可提交;review 待核对;skipped 不匹配;updated 已修改。 */
+    private String decision;
+    private String reason;
+    private Long yearDifferenceMillis;
+    private Integer orderCount;
+    private Boolean expiredAfterRepair;
+}

+ 17 - 0
netflix-dao/src/main/java/com/cyksj/model/response/TicketDurationRepairPreviewResp.java

@@ -0,0 +1,17 @@
+package com.cyksj.model.response;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+@Getter
+@Setter
+public class TicketDurationRepairPreviewResp {
+    /** 本次预览指定的用户;null表示未按用户筛选。 */
+    private Long userId;
+    private Long afterId;
+    private Long nextAfterId;
+    private boolean hasMore;
+    private List<TicketDurationRepairItemResp> items;
+}

+ 115 - 0
netflix-dao/src/main/resources/mapper/TicketDurationRepairMapper.xml

@@ -0,0 +1,115 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.cyksj.mapper.TicketDurationRepairMapper">
+
+    <resultMap id="repairRow" type="com.cyksj.model.dto.TicketDurationRepairRow">
+        <id property="relationId" column="relation_id"/>
+        <result property="groupsId" column="groups_id"/>
+        <result property="skuId" column="sku_id"/>
+        <result property="userId" column="user_id"/>
+        <result property="status" column="status"/>
+        <result property="startTime" column="start_time"/>
+        <result property="expiryTime" column="expiry_time"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="isFrozen" column="is_frozen"/>
+        <result property="isRecover" column="is_recover"/>
+        <result property="isVip" column="is_vip"/>
+        <result property="reservedDays" column="reserved_days"/>
+        <result property="compensationDays" column="compensation_days"/>
+        <result property="aqType" column="aq_type"/>
+    </resultMap>
+
+    <resultMap id="repairOrder" type="com.cyksj.model.dto.TicketDurationRepairOrder">
+        <result property="relationId" column="relation_id"/>
+        <result property="orderId" column="order_id"/>
+        <result property="skuId" column="sku_id"/>
+        <result property="orderType" column="order_type"/>
+        <result property="num" column="num"/>
+        <result property="slot" column="slot"/>
+    </resultMap>
+
+    <sql id="ticketColumns">
+        gr.id AS relation_id, gr.groups_id, gt.sku_id, gr.user_id, gr.status,
+        gr.start_time, gr.expiry_time, gr.update_time, gr.is_frozen,
+        gr.is_recover, gr.is_vip, gr.reserved_days, gr.compensation_days, gr.aq_type
+    </sql>
+
+    <sql id="relationIdsFilter">
+        <choose>
+            <when test="relationIds != null and relationIds.size() > 0">
+                gr.id IN
+                <foreach collection="relationIds" item="relationId" open="(" separator="," close=")">
+                    #{relationId}
+                </foreach>
+            </when>
+            <otherwise>1 = 0</otherwise>
+        </choose>
+    </sql>
+
+    <select id="selectPage" resultMap="repairRow">
+        SELECT <include refid="ticketColumns"/>
+        FROM groups_relation gr
+        INNER JOIN groups_trips gt ON gt.id = gr.groups_id
+        WHERE gt.sku_id IN (772, 774)
+          AND gr.id &gt; #{afterId}
+        <if test="userId != null">
+          AND gr.user_id = #{userId}
+        </if>
+        ORDER BY gr.id
+        LIMIT #{limit}
+    </select>
+
+    <select id="selectForUpdate" resultMap="repairRow">
+        SELECT <include refid="ticketColumns"/>
+        FROM groups_relation gr
+        INNER JOIN groups_trips gt ON gt.id = gr.groups_id
+        WHERE gr.id = #{relationId}
+          AND gt.sku_id IN (772, 774)
+        FOR UPDATE
+    </select>
+
+    <select id="selectOrders" resultMap="repairOrder">
+        SELECT gr.id AS relation_id, o.id AS order_id, o.sku_id, o.order_type, o.num, 0 AS slot
+        FROM groups_relation gr
+        INNER JOIN order_don o ON o.relation_id = gr.id AND o.user_id = gr.user_id
+        WHERE <include refid="relationIdsFilter"/>
+          AND o.status NOT IN ('close', 'noPayment', 'refund')
+        UNION ALL
+        SELECT gr.id AS relation_id, o.id AS order_id, o.sku_id, o.order_type, o.num, 1 AS slot
+        FROM groups_relation gr
+        INNER JOIN order_don_ticket_record tr ON tr.relation_id = gr.id AND tr.status = 'success'
+        INNER JOIN order_don o ON o.id = tr.order_id AND o.user_id = gr.user_id
+        WHERE <include refid="relationIdsFilter"/>
+          AND o.status NOT IN ('close', 'noPayment', 'refund')
+    </select>
+
+    <select id="selectAdjustedRelationIds" resultType="java.lang.Long">
+        SELECT DISTINCT gr.id
+        FROM groups_relation gr
+        INNER JOIN incr_expiry_time_record r ON r.relation_id = gr.id AND r.user_id = gr.user_id
+        WHERE <include refid="relationIdsFilter"/>
+    </select>
+
+    <update id="updateExpiry">
+        UPDATE groups_relation gr
+        INNER JOIN groups_trips gt ON gt.id = gr.groups_id
+        SET gr.expiry_time = #{expiryTime}, gr.update_time = NOW()
+        WHERE gr.id = #{row.relationId}
+          AND gr.groups_id = #{row.groupsId}
+          AND gr.user_id = #{row.userId}
+          AND gt.sku_id = #{row.skuId}
+          AND gt.sku_id IN (772, 774)
+          AND gr.start_time IS NOT NULL
+          AND gr.expiry_time IS NOT NULL
+          AND gr.start_time &lt;=> #{row.startTime}
+          AND gr.expiry_time &lt;=> #{row.expiryTime}
+          AND gr.update_time &lt;=> #{row.updateTime}
+          AND gr.status &lt;=> #{row.status}
+          AND gr.is_frozen &lt;=> #{row.isFrozen}
+          AND gr.is_recover &lt;=> #{row.isRecover}
+          AND gr.reserved_days &lt;=> #{row.reservedDays}
+          AND gr.compensation_days &lt;=> #{row.compensationDays}
+          AND gr.is_vip &lt;=> #{row.isVip}
+          AND gr.aq_type &lt;=> #{row.aqType}
+    </update>
+</mapper>

+ 13 - 0
netflix-service/src/main/java/com/cyksj/service/relation/TicketDurationRepairService.java

@@ -0,0 +1,13 @@
+package com.cyksj.service.relation;
+
+import com.cyksj.model.request.TicketDurationRepairReq;
+import com.cyksj.model.response.TicketDurationRepairItemResp;
+import com.cyksj.model.response.TicketDurationRepairPreviewResp;
+
+import java.util.List;
+
+public interface TicketDurationRepairService {
+    TicketDurationRepairPreviewResp preview(Long afterId, Integer limit, Long userId);
+
+    List<TicketDurationRepairItemResp> execute(TicketDurationRepairReq req, Long adminId);
+}

+ 248 - 0
netflix-service/src/main/java/com/cyksj/service/relation/impl/TicketDurationRepairServiceImpl.java

@@ -0,0 +1,248 @@
+package com.cyksj.service.relation.impl;
+
+import cn.hutool.core.date.DateUtil;
+import com.cyksj.common.exception.BusinessRuntimeException;
+import com.cyksj.mapper.TicketDurationRepairMapper;
+import com.cyksj.mapper.manage.cms.CmsUserMapper;
+import com.cyksj.mapper.manage.statistics.IncrExpiryTimeRecordMapper;
+import com.cyksj.model.dto.TicketDurationRepairOrder;
+import com.cyksj.model.dto.TicketDurationRepairRow;
+import com.cyksj.model.entity.CmsUser;
+import com.cyksj.model.entity.GroupsRelation;
+import com.cyksj.model.entity.IncrExpiryTimeRecord;
+import com.cyksj.model.request.TicketDurationRepairReq;
+import com.cyksj.model.response.TicketDurationRepairItemResp;
+import com.cyksj.model.response.TicketDurationRepairPreviewResp;
+import com.cyksj.service.relation.TicketDurationRepairService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Isolation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class TicketDurationRepairServiceImpl implements TicketDurationRepairService {
+    private final TicketDurationRepairMapper mapper;
+    private final IncrExpiryTimeRecordMapper recordMapper;
+    private final CmsUserMapper cmsUserMapper;
+
+    @Override
+    @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
+    public TicketDurationRepairPreviewResp preview(Long afterId, Integer limit, Long userId) {
+        if (afterId == null || afterId < 0 || limit == null || limit < 1 || limit > 200) {
+            throw BusinessRuntimeException.getInstance("afterId必须大于等于0,limit必须在1至200之间");
+        }
+        if (userId != null && userId <= 0) {
+            throw BusinessRuntimeException.getInstance("userId必须大于0");
+        }
+        List<TicketDurationRepairRow> fetched = mapper.selectPage(afterId, limit + 1, userId);
+        boolean hasMore = fetched.size() > limit;
+        List<TicketDurationRepairRow> rows = hasMore ? fetched.subList(0, limit) : fetched;
+        TicketDurationRepairPreviewResp response = new TicketDurationRepairPreviewResp();
+        response.setUserId(userId);
+        response.setAfterId(afterId);
+        response.setNextAfterId(rows.isEmpty() ? afterId : rows.get(rows.size() - 1).getRelationId());
+        response.setHasMore(hasMore);
+        response.setItems(classifyAll(rows));
+        return response;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public List<TicketDurationRepairItemResp> execute(TicketDurationRepairReq req, Long adminId) {
+        validateRequest(req, adminId);
+        // Lock in a stable order. The entire batch is validated before the first mutation.
+        List<TicketDurationRepairReq.Snapshot> snapshots = new ArrayList<>(req.getItems());
+        snapshots.sort(Comparator.comparing(TicketDurationRepairReq.Snapshot::getRelationId));
+        List<TicketDurationRepairRow> rows = new ArrayList<>();
+        for (TicketDurationRepairReq.Snapshot snapshot : snapshots) {
+            TicketDurationRepairRow row = mapper.selectForUpdate(snapshot.getRelationId());
+            if (row == null || !matches(row, snapshot)) {
+                throw BusinessRuntimeException.getInstance("车票" + snapshot.getRelationId() + "已变化或不属于目标规格,请重新预览");
+            }
+            rows.add(row);
+        }
+        List<TicketDurationRepairItemResp> results = classifyAll(rows);
+        for (TicketDurationRepairItemResp result : results) {
+            if (!"candidate".equals(result.getDecision())) {
+                throw BusinessRuntimeException.getInstance("车票" + result.getRelationId() + "不能自动修复:" + result.getReason());
+            }
+        }
+        CmsUser admin = cmsUserMapper.selectById(adminId);
+        String operator = admin != null && admin.getNickname() != null
+                ? admin.getNickname() : String.valueOf(adminId);
+        for (int i = 0; i < rows.size(); i++) {
+            TicketDurationRepairRow row = rows.get(i);
+            TicketDurationRepairItemResp result = results.get(i);
+            Date expiryTime = new Date(result.getNewExpiryTime());
+            if (mapper.updateExpiry(row, expiryTime) != 1) {
+                throw BusinessRuntimeException.getInstance("车票" + row.getRelationId() + "更新失败,整批修复已回滚");
+            }
+            IncrExpiryTimeRecord record = new IncrExpiryTimeRecord();
+            record.setRelationId(row.getRelationId());
+            record.setUserId(row.getUserId());
+            record.setExpiryTimeInitial(row.getExpiryTime());
+            // Existing audit schema records days; calculate the actual calendar-day difference.
+            int days = (int) ChronoUnit.DAYS.between(DateUtil.toLocalDateTime(row.getExpiryTime()).toLocalDate(),
+                    DateUtil.toLocalDateTime(expiryTime).toLocalDate());
+            record.setIncrExpiryDays(days);
+            record.setOperator(operator);
+            record.setRemark(req.getRemark() != null && !req.getRemark().isBlank()
+                    ? req.getRemark()
+                    : "规格772/774半年付时长修复:原到期时间减6个自然月;原到期="
+                    + DateUtil.formatDateTime(row.getExpiryTime()) + ";新到期=" + DateUtil.formatDateTime(expiryTime));
+            if (recordMapper.insert(record) != 1) {
+                throw BusinessRuntimeException.getInstance("车票" + row.getRelationId() + "修复记录保存失败,整批修复已回滚");
+            }
+            result.setDecision("updated");
+            result.setReason("已从原到期时间扣减6个自然月");
+        }
+        return results;
+    }
+
+    private List<TicketDurationRepairItemResp> classifyAll(List<TicketDurationRepairRow> rows) {
+        if (rows.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Long> ids = rows.stream().map(TicketDurationRepairRow::getRelationId).collect(Collectors.toList());
+        Map<Long, Map<Long, TicketDurationRepairOrder>> orders = new HashMap<>();
+        for (TicketDurationRepairOrder order : mapper.selectOrders(ids)) {
+            Map<Long, TicketDurationRepairOrder> byId = orders.computeIfAbsent(order.getRelationId(), key -> new HashMap<>());
+            TicketDurationRepairOrder previous = byId.putIfAbsent(order.getOrderId(), order);
+            if (previous != null && Boolean.TRUE.equals(order.getSlot())) {
+                previous.setSlot(true);
+            }
+        }
+        Set<Long> adjusted = new HashSet<>(mapper.selectAdjustedRelationIds(ids));
+        List<TicketDurationRepairItemResp> results = new ArrayList<>();
+        for (TicketDurationRepairRow row : rows) {
+            results.add(classify(row, new ArrayList<>(orders.getOrDefault(row.getRelationId(), Collections.emptyMap()).values()),
+                    adjusted.contains(row.getRelationId())));
+        }
+        return results;
+    }
+
+    private TicketDurationRepairItemResp classify(TicketDurationRepairRow row,
+                                                 List<TicketDurationRepairOrder> orders, boolean adjusted) {
+        TicketDurationRepairItemResp result = new TicketDurationRepairItemResp();
+        result.setRelationId(row.getRelationId());
+        result.setGroupsId(row.getGroupsId());
+        result.setSkuId(row.getSkuId());
+        result.setUserId(row.getUserId());
+        result.setStartTime(millis(row.getStartTime()));
+        result.setExpiryTime(millis(row.getExpiryTime()));
+        result.setUpdateTime(millis(row.getUpdateTime()));
+        result.setOrderCount(orders.size());
+        if (!isTarget(row.getSkuId())) {
+            return decision(result, "skipped", "不是规格772或774");
+        }
+        if (row.getStartTime() == null || row.getExpiryTime() == null) {
+            return decision(result, "skipped", "未设置完整起止时间");
+        }
+        Date expected = DateUtil.offsetMonth(row.getStartTime(), 12);
+        long difference = row.getExpiryTime().getTime() - expected.getTime();
+        result.setYearDifferenceMillis(difference);
+        if (difference != 0) {
+            if (Math.abs(difference) <= 60_000 || DateUtil.isSameDay(expected, row.getExpiryTime())) {
+                return decision(result, "review", "接近一年但存在时间偏差,需核对");
+            }
+            return decision(result, "skipped", "已设置的时长不是12个自然月");
+        }
+        List<String> reasons = new ArrayList<>();
+        if (row.getUserId() == null || row.getUserId() <= 0
+                || (row.getStatus() != GroupsRelation.Status.validity && row.getStatus() != GroupsRelation.Status.outside)) {
+            reasons.add("车票无当前用户或状态无效");
+        }
+        if (Boolean.TRUE.equals(row.getIsFrozen()) || Boolean.TRUE.equals(row.getIsRecover()) || nonzero(row.getReservedDays())) {
+            reasons.add("有冻结/恢复/保留天数");
+        }
+        if (nonzero(row.getCompensationDays()) || adjusted) {
+            reasons.add("有补偿或手工时长调整");
+        }
+        if (Boolean.TRUE.equals(row.getIsVip()) || !Objects.equals(row.getAqType(), 1)) {
+            reasons.add("VIP或非普通购买车票");
+        }
+        if (orders.size() != 1) {
+            reasons.add("有效关联订单不是一笔");
+        } else {
+            TicketDurationRepairOrder order = orders.get(0);
+            if (!Objects.equals(order.getOrderType(), 1)) {
+                reasons.add("续费或未知订单类型");
+            }
+            if (!Objects.equals(order.getSkuId(), row.getSkuId())) {
+                reasons.add("订单规格与当前车票不一致");
+            }
+            if (order.getNum() == null || order.getNum() < 1
+                    || (order.getNum() > 1 && !Boolean.TRUE.equals(order.getSlot()))) {
+                reasons.add("购买数量可能叠加时长");
+            }
+        }
+        if (!reasons.isEmpty()) {
+            return decision(result, "review", String.join(";", reasons));
+        }
+        Date newExpiry = DateUtil.offsetMonth(row.getExpiryTime(), -6);
+        result.setNewExpiryTime(newExpiry.getTime());
+        result.setExpiredAfterRepair(!newExpiry.after(new Date()));
+        return decision(result, "candidate", "可修复:原到期时间减6个自然月");
+    }
+
+    private TicketDurationRepairItemResp decision(TicketDurationRepairItemResp result, String decision, String reason) {
+        result.setDecision(decision);
+        result.setReason(reason);
+        return result;
+    }
+
+    private boolean matches(TicketDurationRepairRow row, TicketDurationRepairReq.Snapshot snapshot) {
+        return Objects.equals(row.getRelationId(), snapshot.getRelationId())
+                && Objects.equals(row.getGroupsId(), snapshot.getGroupsId())
+                && Objects.equals(row.getSkuId(), snapshot.getSkuId()) && isTarget(row.getSkuId())
+                && Objects.equals(row.getUserId(), snapshot.getUserId())
+                && Objects.equals(millis(row.getStartTime()), snapshot.getStartTime())
+                && Objects.equals(millis(row.getExpiryTime()), snapshot.getExpiryTime())
+                && Objects.equals(millis(row.getUpdateTime()), snapshot.getUpdateTime());
+    }
+
+    private void validateRequest(TicketDurationRepairReq req, Long adminId) {
+        if (adminId == null || adminId <= 0 || req == null || req.getItems() == null
+                || req.getItems().isEmpty() || req.getItems().size() > 200) {
+            throw BusinessRuntimeException.getInstance("请以后台账号提交1至200张预览中的车票");
+        }
+        Set<Long> ids = new HashSet<>();
+        for (TicketDurationRepairReq.Snapshot item : req.getItems()) {
+            if (item == null || item.getRelationId() == null || item.getRelationId() <= 0
+                    || item.getGroupsId() == null || item.getGroupsId() <= 0
+                    || item.getUserId() == null || item.getUserId() <= 0 || !isTarget(item.getSkuId())
+                    || item.getStartTime() == null || item.getExpiryTime() == null) {
+                throw BusinessRuntimeException.getInstance("车票快照不完整或规格不属于772/774");
+            }
+            if (!ids.add(item.getRelationId())) {
+                throw BusinessRuntimeException.getInstance("不能重复提交同一张车票");
+            }
+        }
+    }
+
+    private static Long millis(Date date) {
+        return date == null ? null : date.getTime();
+    }
+
+    private static boolean nonzero(Integer value) {
+        return value != null && value != 0;
+    }
+
+    private static boolean isTarget(Long skuId) {
+        return Objects.equals(skuId, 772L) || Objects.equals(skuId, 774L);
+    }
+}

+ 490 - 0
netflix-service/src/test/java/com/cyksj/service/relation/impl/TicketDurationRepairServiceImplTest.java

@@ -0,0 +1,490 @@
+package com.cyksj.service.relation.impl;
+
+import com.cyksj.common.exception.BusinessRuntimeException;
+import com.cyksj.mapper.TicketDurationRepairMapper;
+import com.cyksj.mapper.manage.cms.CmsUserMapper;
+import com.cyksj.mapper.manage.statistics.IncrExpiryTimeRecordMapper;
+import com.cyksj.model.dto.TicketDurationRepairOrder;
+import com.cyksj.model.dto.TicketDurationRepairRow;
+import com.cyksj.model.entity.CmsUser;
+import com.cyksj.model.entity.GroupsRelation;
+import com.cyksj.model.entity.IncrExpiryTimeRecord;
+import com.cyksj.model.request.TicketDurationRepairReq;
+import com.cyksj.model.response.TicketDurationRepairItemResp;
+import com.cyksj.model.response.TicketDurationRepairPreviewResp;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** These tests use mapper mocks; no application context or database is started. */
+class TicketDurationRepairServiceImplTest {
+
+    private TicketDurationRepairMapper mapper;
+    private IncrExpiryTimeRecordMapper recordMapper;
+    private CmsUserMapper cmsUserMapper;
+    private TicketDurationRepairServiceImpl service;
+
+    @BeforeEach
+    void setUp() {
+        mapper = mock(TicketDurationRepairMapper.class);
+        recordMapper = mock(IncrExpiryTimeRecordMapper.class);
+        cmsUserMapper = mock(CmsUserMapper.class);
+        service = new TicketDurationRepairServiceImpl(mapper, recordMapper, cmsUserMapper);
+        when(mapper.selectAdjustedRelationIds(anyList())).thenReturn(Collections.emptyList());
+        CmsUser admin = new CmsUser();
+        admin.setNickname("repair-admin");
+        when(cmsUserMapper.selectById(99L)).thenReturn(admin);
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+            "772, 2025-03-15T12:30:00, 2026-03-15T12:30:00, 2025-09-15T12:30:00",
+            "774, 2023-08-31T12:30:00, 2024-08-31T12:30:00, 2024-02-29T12:30:00",
+            "774, 2024-02-29T12:30:00, 2025-02-28T12:30:00, 2024-08-28T12:30:00"
+    })
+    void previewUsesCalendarMonthsIncludingMonthEndsAndLeapYears(
+            long skuId, String start, String expiry, String repairedExpiry) {
+        TicketDurationRepairRow row = row(10L, skuId, start, expiry);
+        TicketDurationRepairItemResp item = preview(row, ordinaryOrder(row)).getItems().get(0);
+
+        assertEquals("candidate", item.getDecision());
+        assertEquals(date(repairedExpiry).getTime(), item.getNewExpiryTime().longValue());
+        assertEquals(0L, item.getYearDifferenceMillis().longValue());
+        assertEquals(row.getExpiryTime().getTime(), item.getExpiryTime().longValue());
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {-1000L, 1000L})
+    void smallClockDifferencesRequireReview(long differenceMillis) {
+        TicketDurationRepairRow row = annualRow(10L);
+        row.setExpiryTime(new Date(row.getExpiryTime().getTime() + differenceMillis));
+
+        TicketDurationRepairItemResp item = preview(row, ordinaryOrder(row)).getItems().get(0);
+
+        assertEquals("review", item.getDecision());
+        assertEquals(differenceMillis, item.getYearDifferenceMillis().longValue());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"start", "expiry", "both"})
+    void unsetTimesAreSkipped(String absent) {
+        TicketDurationRepairRow row = annualRow(10L);
+        if (!"expiry".equals(absent)) {
+            row.setStartTime(null);
+        }
+        if (!"start".equals(absent)) {
+            row.setExpiryTime(null);
+        }
+
+        assertEquals("skipped", preview(row, ordinaryOrder(row)).getItems().get(0).getDecision());
+        verify(mapper, never()).updateExpiry(any(), any());
+    }
+
+    @Test
+    void existingHalfYearIsSkipped() {
+        TicketDurationRepairRow row = annualRow(10L);
+        row.setExpiryTime(date("2025-09-15T12:30:00"));
+
+        assertEquals("skipped", preview(row, ordinaryOrder(row)).getItems().get(0).getDecision());
+    }
+
+    @Test
+    void renewalIsNotAutomaticallyShortened() {
+        TicketDurationRepairRow row = annualRow(10L);
+        TicketDurationRepairOrder renewal = ordinaryOrder(row);
+        renewal.setOrderType(2);
+
+        assertEquals("review", preview(row, renewal).getItems().get(0).getDecision());
+    }
+
+    @Test
+    void multipleOrdersRequireReviewEvenWhenTheirTotalDurationIsOneYear() {
+        TicketDurationRepairRow row = annualRow(10L);
+        TicketDurationRepairOrder original = ordinaryOrder(row);
+        TicketDurationRepairOrder additional = ordinaryOrder(row);
+        additional.setOrderId(original.getOrderId() + 1);
+
+        assertEquals("review", preview(row, original, additional).getItems().get(0).getDecision());
+    }
+
+    @Test
+    void duplicateDirectAndSlotAssociationsCountAsOneOrder() {
+        TicketDurationRepairRow row = annualRow(10L);
+        TicketDurationRepairOrder direct = ordinaryOrder(row);
+        TicketDurationRepairOrder slot = ordinaryOrder(row);
+        direct.setNum(4);
+        slot.setNum(4);
+        slot.setSlot(true);
+
+        assertEquals("candidate", preview(row, direct, slot).getItems().get(0).getDecision());
+    }
+
+    @Test
+    void slotAssociationAllowsMultiplePurchasedTicketsWithoutTreatingThemAsRenewals() {
+        TicketDurationRepairRow row = annualRow(10L);
+        TicketDurationRepairOrder slot = ordinaryOrder(row);
+        slot.setNum(4);
+        slot.setSlot(true);
+
+        assertEquals("candidate", preview(row, slot).getItems().get(0).getDecision());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"frozen", "recover", "reserved", "compensation", "manual"})
+    void exceptionalDurationHistoryRequiresReview(String history) {
+        TicketDurationRepairRow row = annualRow(10L);
+        switch (history) {
+            case "frozen": row.setIsFrozen(true); break;
+            case "recover": row.setIsRecover(true); break;
+            case "reserved": row.setReservedDays(3); break;
+            case "compensation": row.setCompensationDays(3); break;
+            case "manual":
+                when(mapper.selectAdjustedRelationIds(anyList())).thenReturn(Collections.singletonList(10L));
+                break;
+            default: fail("Unhandled case");
+        }
+
+        assertEquals("review", preview(row, ordinaryOrder(row)).getItems().get(0).getDecision());
+    }
+
+    @Test
+    void missingOrderRequiresReview() {
+        TicketDurationRepairRow row = annualRow(10L);
+
+        assertEquals("review", preview(row).getItems().get(0).getDecision());
+    }
+
+    @Test
+    void previewPaginationDoesNotSkipTheLookaheadRow() {
+        TicketDurationRepairRow first = annualRow(10L);
+        TicketDurationRepairRow second = annualRow(20L);
+        TicketDurationRepairRow lookahead = annualRow(30L);
+        when(mapper.selectPage(0L, 3, null)).thenReturn(Arrays.asList(first, second, lookahead));
+        when(mapper.selectOrders(anyList())).thenReturn(Arrays.asList(
+                ordinaryOrder(first), ordinaryOrder(second), ordinaryOrder(lookahead)));
+
+        TicketDurationRepairPreviewResp result = service.preview(0L, 2, null);
+
+        assertEquals(2, result.getItems().size());
+        assertEquals(20L, result.getNextAfterId().longValue());
+        assertTrue(result.isHasMore());
+        assertNull(result.getUserId());
+        verify(mapper).selectPage(0L, 3, null);
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @Test
+    void previewFiltersByUserAndEchoesTheFilterWithoutWriting() {
+        TicketDurationRepairRow row = annualRow(10L);
+        Long userId = row.getUserId();
+        when(mapper.selectPage(0L, 101, userId)).thenReturn(Collections.singletonList(row));
+        when(mapper.selectOrders(anyList())).thenReturn(Collections.singletonList(ordinaryOrder(row)));
+
+        TicketDurationRepairPreviewResp result = service.preview(0L, 100, userId);
+
+        assertEquals(userId, result.getUserId());
+        assertEquals(1, result.getItems().size());
+        assertEquals(userId, result.getItems().get(0).getUserId());
+        assertEquals("candidate", result.getItems().get(0).getDecision());
+        verify(mapper).selectPage(0L, 101, userId);
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {0L, -1L})
+    void previewRejectsNonPositiveUserIdsBeforeQuerying(long userId) {
+        assertThrows(BusinessRuntimeException.class, () -> service.preview(0L, 100, userId));
+
+        verifyNoInteractions(mapper, recordMapper, cmsUserMapper);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"groups", "sku", "user", "start", "expiry", "update", "nullUpdate"})
+    void changedPreviewSnapshotIsRejectedBeforeWriting(String changed) {
+        TicketDurationRepairRow row = annualRow(10L);
+        TicketDurationRepairReq req = request(row);
+        switch (changed) {
+            case "groups": row.setGroupsId(row.getGroupsId() + 1); break;
+            case "sku": row.setSkuId(774L); break;
+            case "user": row.setUserId(row.getUserId() + 1); break;
+            case "start": row.setStartTime(new Date(row.getStartTime().getTime() + 1000)); break;
+            case "expiry": row.setExpiryTime(new Date(row.getExpiryTime().getTime() + 1000)); break;
+            case "update": row.setUpdateTime(new Date(row.getUpdateTime().getTime() + 1000)); break;
+            case "nullUpdate": row.setUpdateTime(null); break;
+            default: fail("Unhandled case");
+        }
+        arrangeExecution(row);
+
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(req, 99L));
+
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @Test
+    void executionRechecksEligibilityAfterPreview() {
+        TicketDurationRepairRow row = annualRow(10L);
+        TicketDurationRepairReq req = request(row);
+        row.setIsFrozen(true);
+        arrangeExecution(row);
+
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(req, 99L));
+
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @Test
+    void executionRejectsDuplicateIds() {
+        TicketDurationRepairRow row = annualRow(10L);
+        TicketDurationRepairReq req = request(row, row);
+        arrangeExecution(row);
+
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(req, 99L));
+
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @Test
+    void emptyExecutionRequestIsRejected() {
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(request(), 99L));
+
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @Test
+    void executionBatchLargerThanLimitIsRejected() {
+        TicketDurationRepairRow[] rows = new TicketDurationRepairRow[201];
+        for (int index = 0; index < rows.length; index++) {
+            rows[index] = annualRow(index + 1L);
+        }
+
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(request(rows), 99L));
+
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @Test
+    void missingTicketIsRejected() {
+        TicketDurationRepairRow row = annualRow(10L);
+
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(request(row), 99L));
+
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @Test
+    void validatesEverySnapshotBeforeUpdatingAnyTicket() {
+        TicketDurationRepairRow first = annualRow(10L);
+        TicketDurationRepairRow second = annualRow(20L);
+        TicketDurationRepairReq req = request(first, second);
+        second.setUserId(second.getUserId() + 1);
+        arrangeExecution(first, second);
+
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(req, 99L));
+
+        verify(mapper, never()).updateExpiry(any(), any());
+        verifyNoInteractions(recordMapper);
+    }
+
+    @Test
+    void failedConditionalUpdateDoesNotWriteAnAuditRecord() {
+        TicketDurationRepairRow row = annualRow(10L);
+        arrangeExecution(row);
+        when(mapper.updateExpiry(any(), any())).thenReturn(0);
+
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(request(row), 99L));
+
+        verifyNoInteractions(recordMapper);
+    }
+
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(strings = {" \t\n"})
+    void successfulExecutionRecordsOldExpiryAndNegativeDayAdjustmentWithDefaultRemark(String remark) {
+        TicketDurationRepairRow row = annualRow(10L);
+        Date oldExpiry = new Date(row.getExpiryTime().getTime());
+        TicketDurationRepairReq req = request(row);
+        req.setRemark(remark);
+        arrangeExecution(row);
+        when(mapper.updateExpiry(any(), any())).thenReturn(1);
+        when(recordMapper.insert(any(IncrExpiryTimeRecord.class))).thenReturn(1);
+
+        List<TicketDurationRepairItemResp> result = service.execute(req, 99L);
+
+        verify(mapper).updateExpiry(any(TicketDurationRepairRow.class), eq(date("2025-09-15T12:30:00")));
+        ArgumentCaptor<IncrExpiryTimeRecord> audit = ArgumentCaptor.forClass(IncrExpiryTimeRecord.class);
+        verify(recordMapper).insert(audit.capture());
+        assertEquals(10L, audit.getValue().getRelationId().longValue());
+        assertEquals(row.getUserId(), audit.getValue().getUserId());
+        assertEquals(oldExpiry, audit.getValue().getExpiryTimeInitial());
+        assertTrue(audit.getValue().getIncrExpiryDays() < 0);
+        assertEquals("规格772/774半年付时长修复:原到期时间减6个自然月;原到期="
+                + "2026-03-15 12:30:00;新到期=2025-09-15 12:30:00", audit.getValue().getRemark());
+        assertEquals(1, result.size());
+        assertEquals("updated", result.get(0).getDecision());
+        assertEquals(date("2025-09-15T12:30:00").getTime(), result.get(0).getNewExpiryTime().longValue());
+    }
+
+    @Test
+    void customRemarkIsSavedVerbatimForEveryTicketInTheBatch() {
+        TicketDurationRepairRow first = annualRow(10L);
+        TicketDurationRepairRow second = annualRow(20L);
+        second.setSkuId(774L);
+        String remark = "  半年付时长误设为一年,已核实扣除半年。\n客户备注保留  ";
+        TicketDurationRepairReq req = request(first, second);
+        req.setRemark(remark);
+        arrangeExecution(first, second);
+        when(mapper.updateExpiry(any(), any())).thenReturn(1);
+        when(recordMapper.insert(any(IncrExpiryTimeRecord.class))).thenReturn(1);
+
+        List<TicketDurationRepairItemResp> result = service.execute(req, 99L);
+
+        ArgumentCaptor<IncrExpiryTimeRecord> audit = ArgumentCaptor.forClass(IncrExpiryTimeRecord.class);
+        verify(recordMapper, times(2)).insert(audit.capture());
+        assertEquals(2, result.size());
+        assertEquals(10L, audit.getAllValues().get(0).getRelationId().longValue());
+        assertEquals(20L, audit.getAllValues().get(1).getRelationId().longValue());
+        for (IncrExpiryTimeRecord record : audit.getAllValues()) {
+            assertEquals(remark, record.getRemark());
+            assertEquals("repair-admin", record.getOperator());
+            assertTrue(record.getIncrExpiryDays() < 0);
+        }
+    }
+
+    @Test
+    void unchangedNullUpdateTimeCanBeRepaired() {
+        TicketDurationRepairRow row = annualRow(10L);
+        row.setUpdateTime(null);
+        arrangeExecution(row);
+        when(mapper.updateExpiry(any(), any())).thenReturn(1);
+        when(recordMapper.insert(any(IncrExpiryTimeRecord.class))).thenReturn(1);
+
+        assertEquals(1, service.execute(request(row), 99L).size());
+
+        verify(mapper).updateExpiry(any(), eq(date("2025-09-15T12:30:00")));
+        verify(recordMapper).insert(any(IncrExpiryTimeRecord.class));
+    }
+
+    @Test
+    void unsuccessfulAuditInsertionRaisesAnErrorToRollbackTheTransaction() {
+        TicketDurationRepairRow row = annualRow(10L);
+        arrangeExecution(row);
+        when(mapper.updateExpiry(any(), any())).thenReturn(1);
+        when(recordMapper.insert(any(IncrExpiryTimeRecord.class))).thenReturn(0);
+
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(request(row), 99L));
+    }
+
+    @Test
+    void repeatExecutionCannotDeductAnotherSixMonths() {
+        TicketDurationRepairRow row = annualRow(10L);
+        TicketDurationRepairReq originalRequest = request(row);
+        arrangeExecution(row);
+        when(mapper.updateExpiry(any(), any())).thenReturn(1);
+        when(recordMapper.insert(any(IncrExpiryTimeRecord.class))).thenReturn(1);
+        service.execute(originalRequest, 99L);
+        row.setExpiryTime(date("2025-09-15T12:30:00"));
+        row.setUpdateTime(new Date(row.getUpdateTime().getTime() + 1000));
+
+        assertThrows(BusinessRuntimeException.class, () -> service.execute(originalRequest, 99L));
+
+        verify(mapper, times(1)).updateExpiry(any(), any());
+        verify(recordMapper, times(1)).insert(any(IncrExpiryTimeRecord.class));
+    }
+
+    private TicketDurationRepairPreviewResp preview(TicketDurationRepairRow row,
+                                                   TicketDurationRepairOrder... orders) {
+        when(mapper.selectPage(0L, 101, null)).thenReturn(Collections.singletonList(row));
+        when(mapper.selectOrders(anyList())).thenReturn(Arrays.asList(orders));
+        return service.preview(0L, 100, null);
+    }
+
+    private void arrangeExecution(TicketDurationRepairRow... rows) {
+        for (TicketDurationRepairRow row : rows) {
+            when(mapper.selectForUpdate(row.getRelationId())).thenReturn(row);
+        }
+        TicketDurationRepairOrder[] orders = Arrays.stream(rows)
+                .map(this::ordinaryOrder).toArray(TicketDurationRepairOrder[]::new);
+        when(mapper.selectOrders(anyList())).thenReturn(Arrays.asList(orders));
+    }
+
+    private TicketDurationRepairRow annualRow(long relationId) {
+        return row(relationId, 772L, "2025-03-15T12:30:00", "2026-03-15T12:30:00");
+    }
+
+    private TicketDurationRepairRow row(long relationId, long skuId, String start, String expiry) {
+        TicketDurationRepairRow row = new TicketDurationRepairRow();
+        row.setRelationId(relationId);
+        row.setGroupsId(100L);
+        row.setSkuId(skuId);
+        row.setUserId(200L);
+        row.setStatus(GroupsRelation.Status.validity);
+        row.setStartTime(date(start));
+        row.setExpiryTime(date(expiry));
+        row.setUpdateTime(date("2025-03-16T08:00:00"));
+        row.setIsFrozen(false);
+        row.setIsRecover(false);
+        row.setIsVip(false);
+        row.setReservedDays(0);
+        row.setCompensationDays(0);
+        row.setAqType(1);
+        return row;
+    }
+
+    private TicketDurationRepairOrder ordinaryOrder(TicketDurationRepairRow row) {
+        TicketDurationRepairOrder order = new TicketDurationRepairOrder();
+        order.setRelationId(row.getRelationId());
+        order.setOrderId(row.getRelationId() + 1000L);
+        order.setSkuId(row.getSkuId());
+        order.setOrderType(1);
+        order.setNum(1);
+        order.setSlot(false);
+        return order;
+    }
+
+    private TicketDurationRepairReq request(TicketDurationRepairRow... rows) {
+        TicketDurationRepairReq req = new TicketDurationRepairReq();
+        java.util.ArrayList<TicketDurationRepairReq.Snapshot> items = new java.util.ArrayList<>();
+        for (TicketDurationRepairRow row : rows) {
+            TicketDurationRepairReq.Snapshot item = new TicketDurationRepairReq.Snapshot();
+            item.setRelationId(row.getRelationId());
+            item.setGroupsId(row.getGroupsId());
+            item.setSkuId(row.getSkuId());
+            item.setUserId(row.getUserId());
+            item.setStartTime(row.getStartTime() == null ? null : row.getStartTime().getTime());
+            item.setExpiryTime(row.getExpiryTime() == null ? null : row.getExpiryTime().getTime());
+            item.setUpdateTime(row.getUpdateTime() == null ? null : row.getUpdateTime().getTime());
+            items.add(item);
+        }
+        req.setItems(items);
+        return req;
+    }
+
+    private Date date(String value) {
+        return Date.from(LocalDateTime.parse(value).atZone(ZoneId.systemDefault()).toInstant());
+    }
+}

+ 97 - 0
netflix-web/TICKET_DURATION_REPAIR_API.md

@@ -0,0 +1,97 @@
+# 半年付车票时长修复 API
+
+固定处理规格 **772、774**。沿用后台登录凭证,要求 `validAccount:addTime` 权限。
+修改已开通车票的到期时间,不修改规格默认月份、开始时间或订单金额。
+
+## 1. 预览
+
+```http
+GET /manage/group/relation/duration-repair/preview?afterId=0&limit=100
+```
+
+可以传可选参数 `userId`,先只查看一个用户的车票,例如:
+
+```http
+GET /manage/group/relation/duration-repair/preview?userId=12345&afterId=0&limit=100
+```
+
+将 `12345` 替换为要核对的用户 ID。传入后只查询 `groups_relation.user_id` 为该用户、
+当前规格为 772/774 的车票;不会扩展到关联账号。不传则保留按这两个规格预览所有用户的行为。
+`userId` 必须大于 0。预览仅查询,不执行扣减。
+
+沿用项目 `Result` 响应包装,业务数据含 `userId`、`items`、`hasMore`、`nextAfterId`。
+`hasMore=true` 时,将 `nextAfterId` 作为下次 `afterId`,同时保留相同的 `userId`;每页最多 200 张。
+
+每条记录包括:
+
+| 字段 | 含义 |
+| --- | --- |
+| relationId / groupsId / skuId / userId | 车票、车队、规格、当前用户 |
+| startTime / expiryTime / updateTime | 当前开始、到期、更新时间,均为毫秒时间戳 |
+| newExpiryTime | 候选车票预计的新到期时间,原到期减 6 个自然月 |
+| yearDifferenceMillis | 原到期与开始加 12 个月的毫秒差 |
+| orderCount | 当前车票用户关联的有效订单数,已按订单去重 |
+| decision / reason | 筛选结果与原因 |
+| expiredAfterRepair | 候选车票扣减后是否已到期 |
+
+`decision`:
+
+- `candidate`:起止完整、精确 12 个自然月,当前有用户且为有效/额外车位;普通购买、非 VIP、
+  无冻结、恢复、保留天数或补时,且只关联一笔同规格普通订单。可提交执行。
+- `review`:有秒差、续费、多订单、补时、冻结等情况;不自动扣减。
+- `skipped`:未设置时间、已经半年或其他不符合条件的时长。
+
+月份运算使用项目原有 `DateUtil.offsetMonth`;不按 180 天、不用完整月份数取整判断。
+例如开始为 `2024-02-29`、原到期 `2025-02-28`,扣减后为 `2024-08-28`。
+候选筛查无法证明所有历史修改原因,提交前仍需核对预览是否属于本次误设范围。
+
+## 2. 执行
+
+```http
+POST /manage/group/relation/duration-repair/execute
+Content-Type: application/json
+```
+
+将预览中需要处理的 `candidate` 行的以下快照字段原样回传,时间必须用预览返回值:
+
+```json
+{
+  "remark": "半年付套餐误设一年,扣回多发的半年时长",
+  "items": [
+    {
+      "relationId": 123,
+      "groupsId": 456,
+      "skuId": 772,
+      "userId": 789,
+      "startTime": 1767225600000,
+      "expiryTime": 1798761600000,
+      "updateTime": 1767225600000
+    }
+  ]
+}
+```
+
+上面的 ID 和时间只是请求格式示例。`updateTime` 若在预览中为 `null`,也传 `null`。
+`remark` 是可选的自定义备注,有内容时原样写入本批每张车票的 `incr_expiry_time_record.remark`,
+不会追加默认说明;不传、空字符串或纯空白时,使用默认修复说明(包含新旧到期时间)。
+每批 1 至 200 张,不能包含重复车票;不接受任意规格、任意扣减月份或强制跳过校验。
+
+执行时锁定车票、再次查询订单和补时历史,对比身份及起止/更新时间,整批验证后扣减。
+任一车票已经变化、不满足条件、更新失败或审计记录保存失败,整批事务回滚。
+成功返回每张车票的修改前后时间,`decision=updated`。
+再次提交同一快照会因旧到期时间不一致被拒绝,避免重复扣减。
+
+每张修复车票写入现有 `incr_expiry_time_record`:旧到期时间、实际扣减天数(负数)、
+操作人及备注;未提供自定义备注时,备注记录扣减 6 个自然月的说明及新旧到期时间。
+后台同时记录 API 操作日志。
+如果扣减后已经到期,接口仍保存该到期时间,不在本次事务内清票或调用外部服务,
+预览和执行响应中的 `expiredAfterRepair` 会标明这种情况。
+
+## 验证
+
+```powershell
+mvn -pl netflix-service -am '-Dtest=TicketDurationRepairServiceImplTest' '-Dsurefire.failIfNoSpecifiedTests=false' test
+mvn -pl netflix-web -am '-DskipTests' compile
+```
+
+测试使用 Mockito,不连接业务数据库。部署后使用现有后台会话调用以上接口。

+ 48 - 0
netflix-web/src/main/java/com/cyksj/web/controller/manage/group/CmsTicketDurationRepairController.java

@@ -0,0 +1,48 @@
+package com.cyksj.web.controller.manage.group;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.stp.StpUtil;
+import com.cyksj.common.annotation.Log;
+import com.cyksj.common.annotation.NoSubmit;
+import com.cyksj.dto.Result;
+import com.cyksj.enums.BusinessType;
+import com.cyksj.enums.GatewayResponse;
+import com.cyksj.model.request.TicketDurationRepairReq;
+import com.cyksj.model.response.TicketDurationRepairItemResp;
+import com.cyksj.model.response.TicketDurationRepairPreviewResp;
+import com.cyksj.service.relation.TicketDurationRepairService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/** 规格772/774半年付误设一年修复,仅面向有车票时长修改权限的后台账号。 */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/manage/group/relation/duration-repair")
+@SaCheckPermission("validAccount:addTime")
+public class CmsTicketDurationRepairController {
+    private final TicketDurationRepairService service;
+
+    @GetMapping("/preview")
+    public Result<TicketDurationRepairPreviewResp> preview(
+            @RequestParam(defaultValue = "0") Long afterId,
+            @RequestParam(defaultValue = "100") Integer limit,
+            @RequestParam(required = false) Long userId) {
+        StpUtil.checkLogin();
+        return GatewayResponse.SUCCESS.newBuilder().toResult(service.preview(afterId, limit, userId));
+    }
+
+    @PostMapping("/execute")
+    @NoSubmit
+    @Log(module = "车票管理/半年付时长修复", businessType = BusinessType.POST, isSaveRequestData = true)
+    public Result<List<TicketDurationRepairItemResp>> execute(@RequestBody @Validated TicketDurationRepairReq req) {
+        return GatewayResponse.SUCCESS.newBuilder().toResult(service.execute(req, StpUtil.getLoginIdAsLong()));
+    }
+}

+ 65 - 0
tools/duration-repair/README.md

@@ -0,0 +1,65 @@
+# 规格 772 / 774 时长检查与修复 SQL 生成
+
+独立 Java 11 程序,只读数据库,不启动 Spring、不执行修复、不修改规格配置。
+从真实车票和订单生成 `preview.csv` 与逐条带旧值条件的 `repair.sql`。
+数据库表、字段缺失或查询失败时会报错停止,不会忽略失败后生成不完整的修复清单。
+
+## 为什么不只判断 SQL 等号
+
+`expiry_time = DATE_ADD(start_time, INTERVAL 12 MONTH)` 判断精确的 12 个自然月,
+但部分开通入口使用两次当前时间,可能有秒差;正常半年续费累计一年也会满足等号。
+程序展示与“开始时间 + 12 个月”的秒差,并读取当前车票用户的关联订单、成功发放车票的记录和补时记录。
+订单通过 `order_don.relation_id` 和 `order_don_ticket_record` 两种关联查找并去重。
+
+分类规则:
+
+- `candidate`:规格为 772/774,当前有用户且车票状态为 validity/outside,起止完整、精确一年;
+  普通购买、非 VIP、无冻结/恢复/保留天数、无补偿/手工补时记录,且只有一笔同规格普通订单。
+  历史多数量叠加时长不进入候选;有成功发放独立车票记录的多数量订单按单张票处理。
+- `review`:时间在一年附近(误差不超过 60 秒或到期自然日期相同),或存在续费、多笔订单、补时、
+  冻结等需要核对的情况。**不生成更新 SQL,也不自动接受任何秒差。**
+- `skipped`:未设置完整起止时间或时长不属于一年附近,例如已经是半年。
+
+候选只是筛查结果,不是误设的绝对证明。历史换票、未记录的手工改期等仍需核对业务记录。
+扣减按**原到期时间减 6 个自然月**,不按 180 天/183 天,也不重算为开始时间加半年。
+例如 `2024-02-29 → 2025-02-28` 扣减后到期为 `2024-08-28`。
+起止时间缺失、冻结保留权益及续费累计时间均不擅自修正。
+
+## 在 PowerShell 运行
+
+需要 Java 11+、MySQL Connector/J 8 驱动以及目标数据库的查询权限。程序不会自动读取项目生产配置。
+数据库应含本项目的 `groups_relation`、`groups_trips`、`order_don`、
+`order_don_ticket_record`、`incr_expiry_time_record` 表。使用事务表可以获得一致快照。
+
+在项目根目录执行,将主机、库名和用户名替换成目标环境:
+
+```powershell
+$env:DURATION_REPAIR_JDBC_URL = 'jdbc:mysql://数据库主机:3306/数据库名?serverTimezone=Asia/Shanghai&characterEncoding=UTF-8'
+$env:DURATION_REPAIR_DB_USER = '数据库查询用户'
+$repairDriver = Join-Path $env:USERPROFILE '.m2\repository\mysql\mysql-connector-java\8.0.23\mysql-connector-java-8.0.23.jar'
+$repairOutput = Join-Path $env:TEMP ('duration-repair-output-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
+java '-Dfile.encoding=UTF-8' --class-path $repairDriver tools/duration-repair/RepairSkuDuration.java $repairOutput
+```
+
+终端会隐藏输入数据库密码。非交互运行时通过 `DURATION_REPAIR_DB_PASSWORD` 环境变量提供密码,
+不要把密码写进 JDBC URL、源代码或命令行参数。输出目录必须不存在,其父目录必须存在。
+
+先查看 `preview.csv` 中每条候选的订单和新旧到期时间,再在数据库客户端打开 `repair.sql`。
+SQL 使用车票 ID、当前用户、所属车队/规格、原开始/到期/更新时间和状态作为条件。
+每条更新紧跟 `ROW_COUNT()`,预期为 1;为 0 表示已修复或记录发生变化,需重新检查。
+旧到期条件可防止同一清单重复扣减。事务末尾的 `COMMIT` / `ROLLBACK` 均注释,
+必须在同一连接内按核对结果执行其中一个,不要执行一半后遗留未结束事务。
+
+程序只生成车票修复 SQL。默认规格仍为 12 个月时,另行在后台改为 6 个月,
+并核对 `days`(天数大于 0 时系统优先使用天数),避免新订单继续错误开通。
+
+## 离线验证
+
+测试不连接数据库,也不加载 JDBC 驱动:
+
+```powershell
+$repairClasses = Join-Path $env:TEMP ('duration-repair-test-' + [guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $repairClasses | Out-Null
+javac -encoding UTF-8 -d $repairClasses tools/duration-repair/RepairSkuDuration.java tools/duration-repair/RepairSkuDurationTest.java
+java '-Dfile.encoding=UTF-8' -cp $repairClasses RepairSkuDurationTest
+```

+ 342 - 0
tools/duration-repair/RepairSkuDuration.java

@@ -0,0 +1,342 @@
+import java.io.Console;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.stream.Collectors;
+
+/** Standalone, read-only MySQL inspection. Never starts the Spring application. */
+public final class RepairSkuDuration {
+    private static final DateTimeFormatter SQL_TIME =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS");
+    private static final String VALID_ORDER = "o.status NOT IN ('close','noPayment','refund')";
+
+    static final class Ticket {
+        long id, groupsId, skuId, userId;
+        String status;
+        LocalDateTime start, expiry, updated;
+        boolean frozen, recover, vip;
+        int reservedDays, compensationDays;
+        int aqType = 1;
+    }
+
+    static final class Purchase {
+        long id, skuId;
+        int orderType = 1, quantity = 1;
+        boolean slot;
+    }
+
+    static final class Decision {
+        final String kind, reason;
+        final LocalDateTime newExpiry;
+        final double deltaSeconds;
+
+        Decision(String kind, String reason, LocalDateTime newExpiry, double deltaSeconds) {
+            this.kind = kind;
+            this.reason = reason;
+            this.newExpiry = newExpiry;
+            this.deltaSeconds = deltaSeconds;
+        }
+    }
+
+    static Decision classify(Ticket ticket, List<Purchase> purchases, int adjustmentCount) {
+        if (ticket.skuId != 772 && ticket.skuId != 774) {
+            return new Decision("skipped", "其他规格", null, Double.NaN);
+        }
+        if (ticket.start == null || ticket.expiry == null) {
+            return new Decision("skipped", "未设置完整起止时间", null, Double.NaN);
+        }
+        LocalDateTime expected = ticket.start.plusMonths(12);
+        Duration delta = Duration.between(expected, ticket.expiry);
+        double deltaSeconds = delta.getSeconds() + delta.getNano() / 1_000_000_000.0;
+        if (!delta.isZero()) {
+            // Include small differences straddling midnight, but never silently accept them.
+            if (Math.abs(deltaSeconds) <= 60
+                    || expected.toLocalDate().equals(ticket.expiry.toLocalDate())) {
+                return new Decision("review", "接近一年,但时间不精确相等", null, deltaSeconds);
+            }
+            return new Decision("skipped", "时长不是12个自然月", null, deltaSeconds);
+        }
+        List<String> reasons = new ArrayList<>();
+        if (ticket.userId <= 0 || !("validity".equals(ticket.status) || "outside".equals(ticket.status))) {
+            reasons.add("无当前用户或车票状态需核对");
+        }
+        if (ticket.frozen || ticket.recover || ticket.reservedDays != 0) {
+            reasons.add("冻结/恢复/保留天数");
+        }
+        if (ticket.compensationDays != 0 || adjustmentCount != 0) {
+            reasons.add("存在补偿或手工时长调整");
+        }
+        if (ticket.vip || ticket.aqType != 1) {
+            reasons.add("VIP或非普通购买车票");
+        }
+        if (purchases.size() != 1) {
+            reasons.add("有效关联订单不是一笔");
+        } else {
+            Purchase purchase = purchases.get(0);
+            if (purchase.orderType != 1) {
+                reasons.add("续费或未知订单类型");
+            }
+            if (purchase.skuId != ticket.skuId) {
+                reasons.add("订单规格与当前车票不一致");
+            }
+            if (purchase.quantity < 1 || (purchase.quantity > 1 && !purchase.slot)) {
+                reasons.add("购买数量可能叠加时长");
+            }
+        }
+        if (!reasons.isEmpty()) {
+            return new Decision("review", String.join(";", reasons), null, deltaSeconds);
+        }
+        return new Decision("candidate", "精确一年且未发现续费/补时等情况,需核对后执行SQL",
+                ticket.expiry.minusMonths(6), deltaSeconds);
+    }
+
+    // The generated update requires the inspected identity, timestamps and state to be unchanged.
+    static String updateSql(Ticket ticket) {
+        if ((ticket.skuId != 772 && ticket.skuId != 774) || ticket.start == null || ticket.expiry == null
+                || !ticket.expiry.equals(ticket.start.plusMonths(12))) {
+            throw new IllegalArgumentException("只能为精确一年且起止完整的目标规格生成更新");
+        }
+        return "UPDATE groups_relation gr\n"
+                + "JOIN groups_trips gt ON gt.id = gr.groups_id\n"
+                + "SET gr.expiry_time = " + literal(ticket.expiry.minusMonths(6)) + ", gr.update_time = NOW()\n"
+                + "WHERE gr.id = " + ticket.id + " AND gr.groups_id = " + ticket.groupsId + "\n"
+                + "  AND gt.sku_id = " + ticket.skuId + " AND gr.user_id = " + ticket.userId + "\n"
+                + "  AND gr.start_time = " + literal(ticket.start) + "\n"
+                + "  AND gr.expiry_time = " + literal(ticket.expiry) + "\n"
+                + "  AND gr.update_time <=> " + literal(ticket.updated) + "\n"
+                + "  AND gr.status = '" + checkedStatus(ticket.status) + "'\n"
+                + "  AND COALESCE(gr.is_frozen, 0) = 0 AND COALESCE(gr.is_recover, 0) = 0\n"
+                + "  AND COALESCE(gr.reserved_days, 0) = 0 AND COALESCE(gr.compensation_days, 0) = 0\n"
+                + "  AND COALESCE(gr.is_vip, 0) = 0 AND gr.aq_type = 1;\n"
+                + "SELECT " + ticket.id + " AS relation_id, ROW_COUNT() AS updated_rows;\n";
+    }
+
+    private static String checkedStatus(String status) {
+        if (!"validity".equals(status) && !"outside".equals(status)) {
+            throw new IllegalArgumentException("非有效车票不能生成更新");
+        }
+        return status;
+    }
+
+    private static String literal(LocalDateTime value) {
+        return value == null ? "NULL" : "'" + SQL_TIME.format(value) + "'";
+    }
+
+    private static LocalDateTime date(ResultSet rs, String name) throws SQLException {
+        // Reading DATETIME text avoids an unintended JVM/JDBC timezone conversion.
+        String value = rs.getString(name);
+        return value == null ? null : LocalDateTime.parse(value.replace(' ', 'T'));
+    }
+
+    private static List<Ticket> tickets(Connection connection) throws SQLException {
+        String sql = "SELECT gr.id, gr.groups_id, gt.sku_id, gr.user_id, gr.status, "
+                + "gr.start_time, gr.expiry_time, gr.update_time, gr.is_frozen, gr.is_recover, "
+                + "gr.reserved_days, gr.compensation_days, gr.aq_type, gr.is_vip "
+                + "FROM groups_relation gr JOIN groups_trips gt ON gt.id = gr.groups_id "
+                + "WHERE gt.sku_id IN (772,774) ORDER BY gt.sku_id, gr.id";
+        List<Ticket> tickets = new ArrayList<>();
+        try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sql)) {
+            while (rs.next()) {
+                Ticket ticket = new Ticket();
+                ticket.id = rs.getLong("id");
+                ticket.groupsId = rs.getLong("groups_id");
+                ticket.skuId = rs.getLong("sku_id");
+                ticket.userId = rs.getLong("user_id");
+                ticket.status = rs.getString("status");
+                ticket.start = date(rs, "start_time");
+                ticket.expiry = date(rs, "expiry_time");
+                ticket.updated = date(rs, "update_time");
+                ticket.frozen = rs.getBoolean("is_frozen");
+                ticket.recover = rs.getBoolean("is_recover");
+                ticket.reservedDays = rs.getInt("reserved_days");
+                ticket.compensationDays = rs.getInt("compensation_days");
+                ticket.aqType = rs.getInt("aq_type");
+                ticket.vip = rs.getBoolean("is_vip");
+                tickets.add(ticket);
+            }
+        }
+        return tickets;
+    }
+
+    private static void loadHistory(Connection connection, List<Ticket> batch,
+                                    Map<Long, List<Purchase>> purchases, Map<Long, Integer> adjustments)
+            throws SQLException {
+        String marks = String.join(",", Collections.nCopies(batch.size(), "?"));
+        String columns = "SELECT gr.id AS relation_id, o.id AS order_id, o.sku_id, o.order_type, o.num, ";
+        String sql = columns + "0 AS via_slot FROM groups_relation gr "
+                + "JOIN order_don o ON o.relation_id = gr.id AND o.user_id = gr.user_id "
+                + "WHERE gr.id IN (" + marks + ") AND " + VALID_ORDER
+                + " UNION ALL " + columns + "1 AS via_slot FROM groups_relation gr "
+                + "JOIN order_don_ticket_record tr ON tr.relation_id = gr.id AND tr.status = 'success' "
+                + "JOIN order_don o ON o.id = tr.order_id AND o.user_id = gr.user_id "
+                + "WHERE gr.id IN (" + marks + ") AND " + VALID_ORDER;
+        Map<Long, Map<Long, Purchase>> distinct = new LinkedHashMap<>();
+        try (PreparedStatement stmt = connection.prepareStatement(sql)) {
+            bindIds(stmt, batch, 1);
+            bindIds(stmt, batch, batch.size() + 1);
+            try (ResultSet rs = stmt.executeQuery()) {
+                while (rs.next()) {
+                    long relationId = rs.getLong("relation_id");
+                    long orderId = rs.getLong("order_id");
+                    Purchase purchase = distinct.computeIfAbsent(relationId, key -> new LinkedHashMap<>())
+                            .computeIfAbsent(orderId, key -> new Purchase());
+                    purchase.id = orderId;
+                    purchase.skuId = rs.getLong("sku_id");
+                    purchase.orderType = rs.getInt("order_type");
+                    purchase.quantity = rs.getInt("num");
+                    purchase.slot |= rs.getBoolean("via_slot");
+                }
+            }
+        }
+        distinct.forEach((id, records) -> purchases.put(id, new ArrayList<>(records.values())));
+        String adjustmentSql = "SELECT gr.id, COUNT(*) AS total FROM groups_relation gr "
+                + "JOIN incr_expiry_time_record r ON r.relation_id = gr.id AND r.user_id = gr.user_id "
+                + "WHERE gr.id IN (" + marks + ") GROUP BY gr.id";
+        try (PreparedStatement stmt = connection.prepareStatement(adjustmentSql)) {
+            bindIds(stmt, batch, 1);
+            try (ResultSet rs = stmt.executeQuery()) {
+                while (rs.next()) {
+                    adjustments.put(rs.getLong("id"), rs.getInt("total"));
+                }
+            }
+        }
+    }
+
+    private static void bindIds(PreparedStatement stmt, List<Ticket> batch, int first) throws SQLException {
+        for (int i = 0; i < batch.size(); i++) {
+            stmt.setLong(first + i, batch.get(i).id);
+        }
+    }
+
+    private static String csv(Object value) {
+        String text = value == null ? "" : value.toString();
+        return "\"" + text.replace("\"", "\"\"") + "\"";
+    }
+
+    private static void writeReports(Path directory, List<Ticket> tickets,
+                                     Map<Long, List<Purchase>> purchases, Map<Long, Integer> adjustments)
+            throws IOException {
+        // A fresh output folder prevents overwriting an earlier inspection.
+        Files.createDirectory(directory);
+        StringBuilder report = new StringBuilder("\uFEFFrelation_id,sku_id,user_id,groups_id,status,start_time,"
+                + "old_expiry_time,proposed_expiry_time,update_time,kind,reason,year_delta_seconds,"
+                + "is_frozen,is_recover,reserved_days,compensation_days,aq_type,is_vip,orders,adjustment_count\n");
+        StringBuilder sql = new StringBuilder("-- 仅包含候选车票;先核对同目录 preview.csv。\n"
+                + "-- 这是日期和关联历史筛查,不能证明所有候选都是误设;人工确认错误范围后执行。\n"
+                + "-- 每条更新使用旧时间和身份校验;updated_rows 应为 1,若为 0 则数据已变化或已修复。\n"
+                + "-- 本程序未执行数据库写入;SQL 按同一连接分段执行,末尾手动提交或回滚。\n"
+                + "-- 事务回滚要求 groups_relation 使用 InnoDB 等事务引擎。\n"
+                + "START TRANSACTION;\n\n");
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        for (Ticket ticket : tickets) {
+            List<Purchase> orders = purchases.getOrDefault(ticket.id, Collections.emptyList());
+            int adjustmentCount = adjustments.getOrDefault(ticket.id, 0);
+            Decision decision = classify(ticket, orders, adjustmentCount);
+            counts.merge(decision.kind, 1, Integer::sum);
+            String orderInfo = orders.stream().map(order -> order.id + ":sku=" + order.skuId
+                    + ":type=" + order.orderType + ":num=" + order.quantity + ":slot=" + order.slot)
+                    .collect(Collectors.joining(";"));
+            Object[] fields = {ticket.id, ticket.skuId, ticket.userId, ticket.groupsId, ticket.status,
+                    ticket.start, ticket.expiry, decision.newExpiry, ticket.updated,
+                    decision.kind, decision.reason, Double.isNaN(decision.deltaSeconds) ? "" : decision.deltaSeconds,
+                    ticket.frozen, ticket.recover, ticket.reservedDays, ticket.compensationDays,
+                    ticket.aqType, ticket.vip, orderInfo, adjustmentCount};
+            report.append(Arrays.stream(fields).map(RepairSkuDuration::csv).collect(Collectors.joining(",")))
+                    .append('\n');
+            if ("candidate".equals(decision.kind)) {
+                sql.append(updateSql(ticket)).append('\n');
+            }
+        }
+        sql.append("-- 在同一连接中二选一执行,必须结束上面的事务:\n-- COMMIT;\n-- ROLLBACK;\n");
+        Files.writeString(directory.resolve("preview.csv"), report, StandardCharsets.UTF_8,
+                StandardOpenOption.CREATE_NEW);
+        Files.writeString(directory.resolve("repair.sql"), sql, StandardCharsets.UTF_8,
+                StandardOpenOption.CREATE_NEW);
+        System.out.println("只读检查完成,未修改数据库。车票总数:" + tickets.size() + ";分类:" + counts);
+        System.out.println("输出目录:" + directory.toAbsolutePath());
+    }
+
+    private static String environment(String name) {
+        String value = System.getenv(name);
+        if (value == null || value.isBlank()) {
+            throw new IllegalArgumentException("请设置环境变量 " + name);
+        }
+        return value;
+    }
+
+    public static void main(String[] args) throws Exception {
+        if (args.length != 1 || "--help".equals(args[0])) {
+            System.out.println("用法:java -cp <MySQL驱动路径> RepairSkuDuration.java <新建输出目录>\n"
+                    + "设置 DURATION_REPAIR_JDBC_URL、DURATION_REPAIR_DB_USER。\n"
+                    + "密码通过终端隐藏输入,或使用 DURATION_REPAIR_DB_PASSWORD。\n"
+                    + "只执行SELECT;生成 preview.csv 和 repair.sql;不修改数据库或规格配置。");
+            return;
+        }
+        Path output = Path.of(args[0]).toAbsolutePath();
+        if (Files.exists(output)) {
+            throw new IllegalArgumentException("输出目录已存在,请使用一个新目录");
+        }
+        String url = environment("DURATION_REPAIR_JDBC_URL");
+        if (!url.startsWith("jdbc:mysql:")) {
+            throw new IllegalArgumentException("仅支持 MySQL JDBC URL");
+        }
+        Properties properties = new Properties();
+        properties.setProperty("user", environment("DURATION_REPAIR_DB_USER"));
+        String password = System.getenv("DURATION_REPAIR_DB_PASSWORD");
+        if (password == null) {
+            Console console = System.console();
+            if (console == null) {
+                throw new IllegalArgumentException("无交互终端,请设置 DURATION_REPAIR_DB_PASSWORD");
+            }
+            char[] chars = console.readPassword("数据库密码:");
+            if (chars == null) {
+                throw new IllegalArgumentException("未输入数据库密码");
+            }
+            password = new String(chars);
+            Arrays.fill(chars, '\0');
+        }
+        properties.setProperty("password", password);
+        properties.setProperty("connectTimeout", "10000");
+        properties.setProperty("socketTimeout", "60000");
+        List<Ticket> tickets;
+        Map<Long, List<Purchase>> purchases = new LinkedHashMap<>();
+        Map<Long, Integer> adjustments = new LinkedHashMap<>();
+        try (Connection connection = DriverManager.getConnection(url, properties)) {
+            connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
+            connection.setReadOnly(true);
+            connection.setAutoCommit(false);
+            try {
+                // Enforce read-only on the server as well as through JDBC.
+                try (Statement stmt = connection.createStatement()) {
+                    stmt.execute("START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY");
+                }
+                tickets = tickets(connection);
+                for (int offset = 0; offset < tickets.size(); offset += 200) {
+                    loadHistory(connection, tickets.subList(offset, Math.min(offset + 200, tickets.size())),
+                            purchases, adjustments);
+                }
+            } finally {
+                connection.rollback();
+            }
+        }
+        writeReports(output, tickets, purchases, adjustments);
+    }
+}

+ 273 - 0
tools/duration-repair/RepairSkuDurationTest.java

@@ -0,0 +1,273 @@
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/** Standalone regression checks; does not connect to a database. */
+public final class RepairSkuDurationTest {
+    private static int checks;
+
+    public static void main(String[] args) {
+        exactYearIsCandidateForBothSkus();
+        calendarMonthArithmeticHandlesLeapDaysAndMonthEnds();
+        timestampDifferencesRequireReview();
+        missingDatesAndOtherDurationsAreSkipped();
+        unrelatedSkusAreSkipped();
+        renewalAndMultiplePurchasesRequireReview();
+        quantityMeansDifferentThingsForLegacyAndSlotOrders();
+        missingOrMismatchedPurchaseRequiresReview();
+        adjustedAndSuspendedTicketsRequireReview();
+        specialAcquisitionRequiresReview();
+        generatedSqlMatchesTheOldSnapshot();
+        nullableUpdatedSnapshotIsPreserved();
+        System.out.println("RepairSkuDurationTest: " + checks + " checks passed");
+    }
+
+    private static void exactYearIsCandidateForBothSkus() {
+        for (long skuId : new long[]{772L, 774L}) {
+            RepairSkuDuration.Ticket ticket = ticket();
+            ticket.skuId = skuId;
+            RepairSkuDuration.Decision decision = classify(ticket);
+            kind("candidate", decision, "exact year, SKU " + skuId);
+            equal(ticket.expiry.minusMonths(6), decision.newExpiry,
+                    "candidate subtracts six calendar months from expiry");
+            equal(0.0, decision.deltaSeconds, "exact year has no timestamp difference");
+        }
+        RepairSkuDuration.Ticket outside = ticket();
+        outside.status = "outside";
+        kind("candidate", classify(outside), "occupied extra seat is supported");
+    }
+
+    private static void calendarMonthArithmeticHandlesLeapDaysAndMonthEnds() {
+        RepairSkuDuration.Ticket leap = ticket();
+        leap.start = time("2024-02-29T10:20:30");
+        leap.expiry = time("2025-02-28T10:20:30");
+        RepairSkuDuration.Decision leapDecision = classify(leap);
+        kind("candidate", leapDecision, "leap-day year uses calendar arithmetic");
+        equal(time("2024-08-28T10:20:30"), leapDecision.newExpiry,
+                "subtract from actual expiry; do not calculate start plus six months");
+
+        RepairSkuDuration.Ticket monthEnd = ticket();
+        monthEnd.start = time("2025-08-31T10:20:30");
+        monthEnd.expiry = time("2026-08-31T10:20:30");
+        RepairSkuDuration.Decision monthEndDecision = classify(monthEnd);
+        kind("candidate", monthEndDecision, "month-end year is recognized");
+        equal(time("2026-02-28T10:20:30"), monthEndDecision.newExpiry,
+                "six-month subtraction clamps to the last day of February");
+
+        RepairSkuDuration.Ticket leapFebruary = ticket();
+        leapFebruary.start = time("2023-08-31T10:20:30");
+        leapFebruary.expiry = time("2024-08-31T10:20:30");
+        equal(time("2024-02-29T10:20:30"), classify(leapFebruary).newExpiry,
+                "six-month subtraction preserves leap February's final day");
+    }
+
+    private static void timestampDifferencesRequireReview() {
+        for (long seconds : new long[]{-1, 1, -30, 30}) {
+            RepairSkuDuration.Ticket ticket = ticket();
+            ticket.expiry = ticket.expiry.plusSeconds(seconds);
+            RepairSkuDuration.Decision decision = classify(ticket);
+            kind("review", decision, "timestamp difference " + seconds + " seconds");
+            equal(Math.abs((double) seconds), Math.abs(decision.deltaSeconds),
+                    "report the timestamp difference accurately");
+        }
+        RepairSkuDuration.Ticket manualTime = ticket();
+        manualTime.expiry = manualTime.expiry.toLocalDate().atStartOfDay();
+        kind("review", classify(manualTime), "same anniversary date with a different time");
+    }
+
+    private static void missingDatesAndOtherDurationsAreSkipped() {
+        RepairSkuDuration.Ticket noStart = ticket();
+        noStart.start = null;
+        kind("skipped", classify(noStart), "missing start time");
+
+        RepairSkuDuration.Ticket noExpiry = ticket();
+        noExpiry.expiry = null;
+        kind("skipped", classify(noExpiry), "missing expiry time");
+
+        RepairSkuDuration.Ticket noDates = ticket();
+        noDates.start = null;
+        noDates.expiry = null;
+        kind("skipped", classify(noDates), "ticket has never been activated");
+
+        for (int months : new int[]{1, 6, 18, 24}) {
+            RepairSkuDuration.Ticket ticket = ticket();
+            ticket.expiry = ticket.start.plusMonths(months);
+            kind("skipped", classify(ticket), "nonannual duration " + months + " months");
+        }
+    }
+
+    private static void unrelatedSkusAreSkipped() {
+        for (long skuId : new long[]{773L, 775L, 1L}) {
+            RepairSkuDuration.Ticket ticket = ticket();
+            ticket.skuId = skuId;
+            kind("skipped", classify(ticket), "unrelated SKU " + skuId);
+        }
+    }
+
+    private static void renewalAndMultiplePurchasesRequireReview() {
+        RepairSkuDuration.Ticket ticket = ticket();
+        RepairSkuDuration.Purchase renewal = purchase(ticket);
+        renewal.orderType = 2;
+        kind("review", classify(ticket, Collections.singletonList(renewal), 0),
+                "a renewal must not be mistaken for an incorrectly configured first purchase");
+
+        RepairSkuDuration.Purchase first = purchase(ticket);
+        RepairSkuDuration.Purchase second = purchase(ticket);
+        second.id++;
+        kind("review", classify(ticket, Arrays.asList(first, second), 0),
+                "two purchases may legitimately add up to a year");
+        second.orderType = 2;
+        kind("review", classify(ticket, Arrays.asList(first, second), 0),
+                "original purchase plus renewal requires review");
+    }
+
+    private static void quantityMeansDifferentThingsForLegacyAndSlotOrders() {
+        RepairSkuDuration.Ticket ticket = ticket();
+        RepairSkuDuration.Purchase purchase = purchase(ticket);
+        purchase.quantity = 2;
+        kind("review", classify(ticket, Collections.singletonList(purchase), 0),
+                "legacy purchase quantity can extend one ticket's duration");
+
+        purchase.slot = true;
+        RepairSkuDuration.Decision decision = classify(ticket, Collections.singletonList(purchase), 0);
+        kind("candidate", decision, "a successful delivery slot represents one ticket");
+        equal(ticket.expiry.minusMonths(6), decision.newExpiry,
+                "slot delivery subtracts six months once, regardless of order quantity");
+    }
+
+    private static void missingOrMismatchedPurchaseRequiresReview() {
+        RepairSkuDuration.Ticket ticket = ticket();
+        kind("review", classify(ticket, Collections.emptyList(), 0),
+                "missing purchase does not prove that the annual duration is wrong");
+
+        RepairSkuDuration.Purchase purchase = purchase(ticket);
+        purchase.skuId = 775L;
+        kind("review", classify(ticket, Collections.singletonList(purchase), 0),
+                "current ticket SKU differs from the purchase SKU");
+    }
+
+    private static void adjustedAndSuspendedTicketsRequireReview() {
+        RepairSkuDuration.Ticket ticket = ticket();
+        kind("review", classify(ticket, Collections.singletonList(purchase(ticket)), 1),
+                "manual adjustment history requires review even if the dates total one year");
+
+        RepairSkuDuration.Ticket compensated = ticket();
+        compensated.compensationDays = 3;
+        kind("review", classify(compensated), "compensated ticket");
+
+        RepairSkuDuration.Ticket frozen = ticket();
+        frozen.frozen = true;
+        kind("review", classify(frozen), "frozen ticket");
+
+        RepairSkuDuration.Ticket recovering = ticket();
+        recovering.recover = true;
+        kind("review", classify(recovering), "ticket being restored");
+
+        RepairSkuDuration.Ticket reserved = ticket();
+        reserved.reservedDays = 15;
+        kind("review", classify(reserved), "ticket with reserved duration");
+    }
+
+    private static void specialAcquisitionRequiresReview() {
+        RepairSkuDuration.Ticket vip = ticket();
+        vip.vip = true;
+        kind("review", classify(vip), "VIP ticket");
+
+        RepairSkuDuration.Ticket trial = ticket();
+        trial.aqType = 2;
+        kind("review", classify(trial), "trial acquisition");
+    }
+
+    private static void generatedSqlMatchesTheOldSnapshot() {
+        RepairSkuDuration.Ticket ticket = ticket();
+        String sql = normalizeSql(RepairSkuDuration.updateSql(ticket));
+        containsPattern(sql, "\\bupdate\\s+groups_relation\\b", "SQL updates the ticket table");
+        snapshotNumber(sql, "id", ticket.id);
+        snapshotNumber(sql, "groups_id", ticket.groupsId);
+        snapshotNumber(sql, "user_id", ticket.userId);
+        snapshotNumber(sql, "sku_id", ticket.skuId);
+        snapshotDate(sql, "start_time", "2026-01-15 10:20:30");
+        snapshotDate(sql, "expiry_time", "2027-01-15 10:20:30");
+        snapshotDate(sql, "update_time", "2026-01-15 10:21:40");
+        containsPattern(sql, "\\bwhere\\b", "SQL contains a guarded WHERE clause");
+    }
+
+    private static void nullableUpdatedSnapshotIsPreserved() {
+        RepairSkuDuration.Ticket ticket = ticket();
+        ticket.updated = null;
+        String sql = normalizeSql(RepairSkuDuration.updateSql(ticket));
+        containsPattern(sql, "\\bupdate_time\\s*(?:is\\s+null|<=>\\s*null)\\b",
+                "null update timestamp is compared as null");
+    }
+
+    private static RepairSkuDuration.Ticket ticket() {
+        RepairSkuDuration.Ticket ticket = new RepairSkuDuration.Ticket();
+        ticket.id = 880011L;
+        ticket.groupsId = 660022L;
+        ticket.skuId = 772L;
+        ticket.userId = 440033L;
+        ticket.status = "validity";
+        ticket.start = time("2026-01-15T10:20:30");
+        ticket.expiry = time("2027-01-15T10:20:30");
+        ticket.updated = time("2026-01-15T10:21:40");
+        ticket.aqType = 1;
+        return ticket;
+    }
+
+    private static RepairSkuDuration.Purchase purchase(RepairSkuDuration.Ticket ticket) {
+        RepairSkuDuration.Purchase purchase = new RepairSkuDuration.Purchase();
+        purchase.id = 330044L;
+        purchase.skuId = ticket.skuId;
+        purchase.orderType = 1;
+        purchase.quantity = 1;
+        return purchase;
+    }
+
+    private static RepairSkuDuration.Decision classify(RepairSkuDuration.Ticket ticket) {
+        return classify(ticket, Collections.singletonList(purchase(ticket)), 0);
+    }
+
+    private static RepairSkuDuration.Decision classify(RepairSkuDuration.Ticket ticket,
+                                                     List<RepairSkuDuration.Purchase> purchases,
+                                                     int adjustmentCount) {
+        return RepairSkuDuration.classify(ticket, purchases, adjustmentCount);
+    }
+
+    private static LocalDateTime time(String value) {
+        return LocalDateTime.parse(value);
+    }
+
+    private static void kind(String expected, RepairSkuDuration.Decision decision, String description) {
+        equal(expected, decision.kind, description + "; reason=" + decision.reason);
+    }
+
+    private static void equal(Object expected, Object actual, String description) {
+        checks++;
+        if (expected == null ? actual != null : !expected.equals(actual)) {
+            throw new AssertionError(description + ": expected=" + expected + ", actual=" + actual);
+        }
+    }
+
+    private static String normalizeSql(String sql) {
+        return sql.replace("`", "").replaceAll("\\s+", " ");
+    }
+
+    private static void snapshotNumber(String sql, String column, long value) {
+        containsPattern(sql, "\\b" + column + "\\s*(?:=|<=>)\\s*" + value + "\\b",
+                "SQL guards the original " + column);
+    }
+
+    private static void snapshotDate(String sql, String column, String value) {
+        containsPattern(sql, "\\b" + column + "\\s*(?:=|<=>)\\s*'" + Pattern.quote(value)
+                        + "(?:\\.0+)?'", "SQL guards the original " + column);
+    }
+
+    private static void containsPattern(String value, String regex, String description) {
+        checks++;
+        if (!Pattern.compile(regex, Pattern.CASE_INSENSITIVE).matcher(value).find()) {
+            throw new AssertionError(description + ": expected pattern=" + regex + ", SQL=" + value);
+        }
+    }
+}