# TORQ Link 구현 지침서

> **패턴 원본**: PhonepayLink (phonepay_links + PhonepayLinkWidgetController)  
> **목표**: 파트너가 TORQ KRW→USDT 결제 링크를 생성 → 고객이 링크로 torq.vue 진입  
> **작성일**: 2026-05-15

---

## 개요

PhonepayLink와 동일한 패턴으로 TORQ 전용 결제 링크를 구현한다.

```
파트너 → partner-api POST /api/v1/torq/links → torq_links 레코드 생성
        → 응답: linkUrl = https://widget.cryptoments.cc/torq/{linkCode}

고객 → widget URL 접속 → torq.vue onMounted
     → open-api GET /widgets/torq/links/{linkCode} (Public, 인증 불필요)
     → 링크 정보 + WidgetSessionData accessToken 발급
     → torq.vue에서 토큰 세팅 후 기존 TORQ 플로우 진행
```

---

## 1. DDL — `torq_links` 테이블

`v2-docs/CRYPTOMENTS_V2_DDL.sql`에 추가 (섹션 1-12):

```sql
-- ───────────────────────────────────────────────────────────────────────
-- 1-12. TORQ 결제 링크 (KRW→USDT 온램프 전용)
-- 파트너가 고객에게 전달하는 TORQ 결제 링크. phonepay_links와 동일 패턴.
-- ───────────────────────────────────────────────────────────────────────
CREATE TABLE torq_links (
    id BIGINT AUTO_INCREMENT PRIMARY KEY
        COMMENT 'PK',
    link_code VARCHAR(20) NOT NULL
        COMMENT '링크 고유 코드 (tlnk_{16자리})',
    partner_id BIGINT NOT NULL
        COMMENT 'partners.id',
    amount BIGINT
        COMMENT 'KRW 금액. NULL이면 고객이 입력',
    partner_user_id VARCHAR(100) NOT NULL
        COMMENT '파트너 측 사용자 식별자',
    partner_reference VARCHAR(200)
        COMMENT '파트너 참조 코드 (주문번호 등)',
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
        COMMENT 'ACTIVE / USED / EXPIRED / CANCELLED / DEACTIVATED / FAILED',
    torq_trade_id BIGINT
        COMMENT 'torq_trades.id — 거래 완료 시 연결',
    expires_at DATETIME(6)
        COMMENT '만료 시각. NULL이면 무기한',
    created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)
        COMMENT '생성일시',
    updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
        COMMENT '수정일시',
    UNIQUE KEY uk_link_code (link_code),
    KEY idx_partner_status (partner_id, status),
    KEY idx_torq_trade (torq_trade_id)
) COMMENT 'TORQ 결제 링크 — 파트너가 고객에게 전달하는 KRW→USDT 온램프 링크';
```

**운영 DB 적용**:
```bash
ssh cryptoments-bastion "ssh db-01 'mysql -u cryptoments -p\"Crypt0m3nts!2026\" cryptoments_db -e \"
CREATE TABLE torq_links (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '\''PK'\'',
    link_code VARCHAR(20) NOT NULL COMMENT '\''링크 고유 코드'\'',
    partner_id BIGINT NOT NULL COMMENT '\''partners.id'\'',
    amount BIGINT COMMENT '\''KRW 금액'\'',
    partner_user_id VARCHAR(100) NOT NULL COMMENT '\''파트너 측 사용자 식별자'\'',
    partner_reference VARCHAR(200) COMMENT '\''파트너 참조 코드'\'',
    status VARCHAR(20) NOT NULL DEFAULT '\''ACTIVE'\'' COMMENT '\''상태'\'',
    torq_trade_id BIGINT COMMENT '\''torq_trades.id'\'',
    expires_at DATETIME(6) COMMENT '\''만료 시각'\'',
    created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) COMMENT '\''생성일시'\'',
    updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '\''수정일시'\'',
    UNIQUE KEY uk_link_code (link_code),
    KEY idx_partner_status (partner_id, status),
    KEY idx_torq_trade (torq_trade_id)
) COMMENT '\''TORQ 결제 링크'\'';
\"'"
```

---

## 2. Enum — `TorqLinkStatus.java`

**파일**: `common/src/main/java/com/cryptoments/common/enums/TorqLinkStatus.java`

```java
package com.cryptoments.common.enums;

/**
 * TORQ 링크 상태.
 * PhonepayLinkStatus와 동일한 상태 머신이지만 별도 enum으로 분리.
 */
public enum TorqLinkStatus {
    /** 활성 — 고객이 결제 가능 */
    ACTIVE,
    /** 사용 완료 — TORQ 거래 COMPLETED */
    USED,
    /** 만료 — expiresAt 경과 */
    EXPIRED,
    /** 취소 — 파트너 수동 취소 */
    CANCELLED,
    /** 파트너가 비활성화 (재활성화 가능) */
    DEACTIVATED,
    /** 실패 — 연결된 거래 FAILED */
    FAILED;
}
```

---

## 3. Entity — `TorqLink.java`

**파일**: `common/src/main/java/com/cryptoments/common/entity/TorqLink.java`

```java
package com.cryptoments.common.entity;

import com.cryptoments.common.enums.TorqLinkStatus;
import lombok.*;
import one.axim.framework.mybatis.annotation.XColumn;
import one.axim.framework.mybatis.annotation.XEntity;

import java.time.LocalDateTime;

/**
 * TORQ 결제 링크 엔티티.
 * phonepay_links와 동일 패턴 — torq_trades.id 참조.
 */
@XEntity("torq_links")
@Getter @Setter @Builder(toBuilder = true)
@NoArgsConstructor @AllArgsConstructor
public class TorqLink {

    @XColumn(value = "id", isPrimaryKey = true, isAutoIncrement = true)
    private Long id;

    /** 링크 고유 코드 (tlnk_{16자리}) */
    @XColumn("link_code")
    private String linkCode;

    /** partners.id */
    @XColumn("partner_id")
    private Long partnerId;

    /** KRW 금액. NULL이면 고객이 입력 */
    @XColumn("amount")
    private Long amount;

    /** 파트너 측 사용자 식별자 */
    @XColumn("partner_user_id")
    private String partnerUserId;

    /** 파트너 참조 코드 (주문번호 등) */
    @XColumn("partner_reference")
    private String partnerReference;

    /** 링크 상태 */
    @XColumn("status")
    private TorqLinkStatus status;

    /** torq_trades.id — 거래 완료 시 연결 */
    @XColumn("torq_trade_id")
    private Long torqTradeId;

    /** 만료 시각. NULL이면 무기한 */
    @XColumn("expires_at")
    private LocalDateTime expiresAt;

    /** 생성일시 */
    @XColumn(value = "created_at", insert = false, update = false)
    private LocalDateTime createdAt;

    /** 수정일시 */
    @XColumn(value = "updated_at", insert = false, update = false)
    private LocalDateTime updatedAt;
}
```

---

## 4. Repository — `TorqLinkRepository.java`

**파일**: `common/src/main/java/com/cryptoments/common/repository/TorqLinkRepository.java`

```java
package com.cryptoments.common.repository;

import com.cryptoments.common.entity.TorqLink;
import one.axim.framework.mybatis.annotation.XRepository;
import one.axim.framework.mybatis.repository.IXRepository;

import java.util.List;

@XRepository
public interface TorqLinkRepository extends IXRepository<Long, TorqLink> {

    TorqLink findByLinkCode(String linkCode);

    List<TorqLink> findByPartnerId(Long partnerId);

    List<TorqLink> findByPartnerIdAndStatus(Long partnerId, String status);
}
```

---

## 5. ErrorCodes 추가

**파일**: `common/src/main/java/com/cryptoments/common/exception/ErrorCodes.java`

기존 ErrorCodes에 추가:

```java
// ── TORQ Link ──
public static final ErrorCode TORQ_LINK_NOT_FOUND = new ErrorCode("450", "TORQ 링크를 찾을 수 없습니다.");
public static final ErrorCode TORQ_LINK_NOT_USABLE = new ErrorCode("451", "사용할 수 없는 TORQ 링크입니다.");
public static final ErrorCode TORQ_LINK_ALREADY_USED = new ErrorCode("452", "이미 사용된 TORQ 링크입니다.");
```

---

## 6. partner-api — `PartnerTorqLinkController.java`

**파일**: `partner-api/src/main/java/com/cryptoments/partnerapi/controller/PartnerTorqLinkController.java`

PhonepayLink의 `PartnerPhonepayController`와 1:1 대응. CRUD 5개 엔드포인트.

```java
package com.cryptoments.partnerapi.controller;

import com.cryptoments.common.entity.TorqLink;
import com.cryptoments.common.enums.TorqLinkStatus;
import com.cryptoments.common.exception.ErrorCodes;
import com.cryptoments.common.exception.NotFoundException;
import com.cryptoments.common.repository.TorqLinkRepository;
import com.cryptoments.partnerapi.dto.request.CreateTorqLinkRequest;
import com.cryptoments.partnerapi.dto.response.TorqLinkResponse;
import com.cryptoments.partnerapi.session.PartnerSessionData;
import jakarta.validation.Valid;
import one.axim.framework.mybatis.model.XPage;
import one.axim.framework.mybatis.model.XPagination;
import one.axim.framework.rest.controller.XSessionController;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.UUID;

/**
 * TORQ 결제 링크 관리 (Partner API).
 *
 * <p>파트너가 고객에게 전달할 TORQ KRW→USDT 결제 링크를 생성/관리한다.
 * PartnerPhonepayController와 동일한 패턴.
 *
 * @group TORQ 링크
 */
@RestController
@RequestMapping("/api/v1/torq/links")
public class PartnerTorqLinkController extends XSessionController<PartnerSessionData> {

    private final TorqLinkRepository torqLinkRepository;

    @Value("${cryptoments.widget.base-url:https://widget.cryptoments.cc}")
    private String widgetBaseUrl;

    public PartnerTorqLinkController(TorqLinkRepository torqLinkRepository) {
        this.torqLinkRepository = torqLinkRepository;
    }

    /**
     * TORQ 링크 생성.
     *
     * @param request 생성 요청 (amount, partnerUserId, partnerReference, expiresInMinutes)
     * @return 생성된 링크 정보 + linkUrl
     * @response 201 생성 성공
     */
    @PostMapping(name = "TORQ 링크 생성")
    @ResponseStatus(HttpStatus.CREATED)
    public TorqLinkResponse createLink(@Valid @RequestBody CreateTorqLinkRequest request) {
        Long partnerId = getSession().getPartnerId();

        String linkCode = "tlnk_" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);

        TorqLink link = TorqLink.builder()
                .partnerId(partnerId)
                .linkCode(linkCode)
                .amount(request.getAmount())
                .partnerUserId(request.getPartnerUserId())
                .partnerReference(request.getPartnerReference())
                .status(TorqLinkStatus.ACTIVE)
                .expiresAt(request.getExpiresInMinutes() != null
                        ? LocalDateTime.now().plusMinutes(request.getExpiresInMinutes()) : null)
                .build();

        Long id = torqLinkRepository.save(link);
        link.setId(id);

        return TorqLinkResponse.from(link, widgetBaseUrl);
    }

    /**
     * TORQ 링크 목록 조회.
     *
     * @param page 페이지 번호 (1-based)
     * @param size 페이지 크기
     * @return 페이지네이션된 링크 목록
     * @response 200 조회 성공
     */
    @GetMapping(name = "TORQ 링크 목록 조회")
    public XPage<TorqLinkResponse> listLinks(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "20") int size) {
        Long partnerId = getSession().getPartnerId();

        XPagination pagination = new XPagination();
        pagination.setPage(page);
        pagination.setSize(size);

        // partnerId 조건 조회 — Mapper 사용 또는 findAll + 필터
        // 간단 구현: findByPartnerId 후 수동 페이징 (데이터 적을 때)
        // 운영 최적화 시 @Mapper + @Select로 전환
        java.util.List<TorqLink> all = torqLinkRepository.findByPartnerId(partnerId);
        int start = (page - 1) * size;
        int end = Math.min(start + size, all.size());

        XPage<TorqLinkResponse> result = new XPage<>();
        result.setPage(page);
        result.setSize(size);
        result.setTotal(all.size());
        result.setList(all.subList(Math.min(start, all.size()), end).stream()
                .map(l -> TorqLinkResponse.from(l, widgetBaseUrl))
                .toList());
        return result;
    }

    /**
     * TORQ 링크 취소.
     *
     * @param linkCode 링크 코드
     * @return 업데이트된 링크 정보
     * @response 200 취소 성공
     * @response 404 링크 없음
     */
    @PostMapping(name = "TORQ 링크 취소", value = "/{linkCode}/cancel")
    public TorqLinkResponse cancelLink(@PathVariable String linkCode) {
        TorqLink link = findMyLink(linkCode);
        link.setStatus(TorqLinkStatus.CANCELLED);
        torqLinkRepository.modify(link);
        return TorqLinkResponse.from(link, widgetBaseUrl);
    }

    /**
     * TORQ 링크 비활성화 (재활성화 가능).
     *
     * @param linkCode 링크 코드
     * @return 업데이트된 링크 정보
     */
    @PostMapping(name = "TORQ 링크 비활성화", value = "/{linkCode}/deactivate")
    public TorqLinkResponse deactivateLink(@PathVariable String linkCode) {
        TorqLink link = findMyLink(linkCode);
        link.setStatus(TorqLinkStatus.DEACTIVATED);
        torqLinkRepository.modify(link);
        return TorqLinkResponse.from(link, widgetBaseUrl);
    }

    /**
     * TORQ 링크 재활성화.
     *
     * @param linkCode 링크 코드
     * @return 업데이트된 링크 정보
     */
    @PostMapping(name = "TORQ 링크 재활성화", value = "/{linkCode}/activate")
    public TorqLinkResponse activateLink(@PathVariable String linkCode) {
        TorqLink link = findMyLink(linkCode);
        link.setStatus(TorqLinkStatus.ACTIVE);
        torqLinkRepository.modify(link);
        return TorqLinkResponse.from(link, widgetBaseUrl);
    }

    /**
     * TORQ 링크 삭제 (soft delete 아님 — 물리 삭제).
     *
     * @param linkCode 링크 코드
     * @response 204 삭제 성공
     */
    @DeleteMapping(name = "TORQ 링크 삭제", value = "/{linkCode}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteLink(@PathVariable String linkCode) {
        TorqLink link = findMyLink(linkCode);
        torqLinkRepository.remove(link.getId());
    }

    // ── helper ──

    private TorqLink findMyLink(String linkCode) {
        TorqLink link = torqLinkRepository.findByLinkCode(linkCode);
        if (link == null) {
            throw new NotFoundException(ErrorCodes.TORQ_LINK_NOT_FOUND);
        }
        Long partnerId = getSession().getPartnerId();
        if (!link.getPartnerId().equals(partnerId)) {
            throw new NotFoundException(ErrorCodes.TORQ_LINK_NOT_FOUND);
        }
        return link;
    }
}
```

---

## 7. partner-api DTO

### 7-1. `CreateTorqLinkRequest.java`

**파일**: `partner-api/src/main/java/com/cryptoments/partnerapi/dto/request/CreateTorqLinkRequest.java`

```java
package com.cryptoments.partnerapi.dto.request;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

/**
 * TORQ 링크 생성 요청.
 */
@Getter @Setter
public class CreateTorqLinkRequest {

    /** KRW 금액. null이면 고객이 직접 입력 */
    private Long amount;

    /** 파트너 사용자 ID (필수) */
    @NotBlank
    private String partnerUserId;

    /** 파트너 내부 참조 코드 (주문번호 등) */
    private String partnerReference;

    /** 만료 시간 (분). null이면 만료 없음 */
    private Integer expiresInMinutes;
}
```

### 7-2. `TorqLinkResponse.java`

**파일**: `partner-api/src/main/java/com/cryptoments/partnerapi/dto/response/TorqLinkResponse.java`

```java
package com.cryptoments.partnerapi.dto.response;

import com.cryptoments.common.entity.TorqLink;
import lombok.*;

import java.time.LocalDateTime;

/**
 * TORQ 링크 응답 (생성/목록 겸용).
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class TorqLinkResponse {

    /** PK */
    private Long id;

    /** 링크 코드 */
    private String linkCode;

    /** KRW 금액. null이면 고객 입력 */
    private Long amount;

    /** 파트너 사용자 ID */
    private String partnerUserId;

    /** 참조 코드 (주문번호 등) */
    private String partnerReference;

    /** 상태 */
    private String status;

    /** TORQ 거래 ID (사용 완료 시) */
    private Long torqTradeId;

    /** 만료 시각 */
    private LocalDateTime expiresAt;

    /** 생성 시각 */
    private LocalDateTime createdAt;

    /** 완성된 링크 URL */
    private String linkUrl;

    public static TorqLinkResponse from(TorqLink link, String widgetBaseUrl) {
        return TorqLinkResponse.builder()
                .id(link.getId())
                .linkCode(link.getLinkCode())
                .amount(link.getAmount())
                .partnerUserId(link.getPartnerUserId())
                .partnerReference(link.getPartnerReference())
                .status(link.getStatus() != null ? link.getStatus().name() : null)
                .torqTradeId(link.getTorqTradeId())
                .expiresAt(link.getExpiresAt())
                .createdAt(link.getCreatedAt())
                .linkUrl(widgetBaseUrl + "/torq/" + link.getLinkCode())
                .build();
    }
}
```

---

## 8. open-api — `TorqLinkWidgetController.java`

**파일**: `open-api/src/main/java/com/cryptoments/openapi/controller/widget/TorqLinkWidgetController.java`

PhonepayLinkWidgetController 패턴. **인증 불필요 (Public API)**.
TORQ는 Axim/eKYC가 필요 없으므로 PhonepayLink보다 단순하다.

```java
package com.cryptoments.openapi.controller.widget;

import com.cryptoments.common.entity.Partner;
import com.cryptoments.common.entity.TorqLink;
import com.cryptoments.common.entity.TorqTrade;
import com.cryptoments.common.enums.TorqLinkStatus;
import com.cryptoments.common.exception.ErrorCodes;
import com.cryptoments.common.exception.NotFoundException;
import com.cryptoments.common.repository.PartnerRepository;
import com.cryptoments.common.repository.TorqLinkRepository;
import com.cryptoments.common.repository.TorqTradeRepository;
import com.cryptoments.openapi.dto.response.TorqLinkInfoResponse;
import com.cryptoments.openapi.dto.response.TorqLinkStatusResponse;
import com.cryptoments.openapi.session.WidgetSessionData;
import one.axim.framework.rest.handler.XBaseAccessTokenHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.Set;

/**
 * TORQ 링크 Widget API.
 *
 * <p>고객이 TORQ 링크 URL(https://widget.cryptoments.cc/torq/{linkCode})을 열면
 * Widget이 이 API를 호출하여:
 * <ol>
 *   <li>링크 정보 조회 (금액, 파트너 정보)</li>
 *   <li>Widget 세션 토큰 발급 (TORQ 스코프)</li>
 *   <li>링크 상태 폴링 (만료/취소/완료 체크)</li>
 * </ol>
 *
 * <p>인증 불필요 (공개 API). PhonepayLinkWidgetController와 동일 패턴.
 *
 * @group TORQ 링크
 */
@RestController
@RequestMapping("/widgets/torq/links")
public class TorqLinkWidgetController {

    private static final Logger log = LoggerFactory.getLogger(TorqLinkWidgetController.class);

    private final TorqLinkRepository torqLinkRepository;
    private final PartnerRepository partnerRepository;
    private final TorqTradeRepository torqTradeRepository;
    private final XBaseAccessTokenHandler tokenHandler;

    public TorqLinkWidgetController(TorqLinkRepository torqLinkRepository,
                                     PartnerRepository partnerRepository,
                                     TorqTradeRepository torqTradeRepository,
                                     XBaseAccessTokenHandler tokenHandler) {
        this.torqLinkRepository = torqLinkRepository;
        this.partnerRepository = partnerRepository;
        this.torqTradeRepository = torqTradeRepository;
        this.tokenHandler = tokenHandler;
    }

    /**
     * TORQ 링크 정보 조회 + Widget 세션 토큰 발급 (Public).
     *
     * <p>Widget이 {@code /torq/{linkCode}} 페이지 진입 시 최초 호출.
     * 만료된 ACTIVE 링크는 EXPIRED로 자동 전환.
     *
     * @param linkCode 링크 코드 (tlnk_xxx)
     * @return 링크 정보 + accessToken (사용 가능 링크일 때)
     * @response 200 조회 성공
     * @response 404 링크 없음
     */
    @GetMapping(name = "TORQ 링크 정보 조회", value = "/{linkCode}")
    public TorqLinkInfoResponse getLinkInfo(@PathVariable String linkCode) {
        TorqLink link = torqLinkRepository.findByLinkCode(linkCode);
        if (link == null) {
            throw new NotFoundException(ErrorCodes.TORQ_LINK_NOT_FOUND);
        }

        autoExpireIfNeeded(link);

        boolean isUsable = (link.getStatus() == TorqLinkStatus.ACTIVE);
        Partner partner = partnerRepository.findOne(link.getPartnerId());

        TorqLinkInfoResponse.TorqLinkInfoResponseBuilder builder = TorqLinkInfoResponse.builder()
                .linkCode(link.getLinkCode())
                .partnerName(partner != null ? partner.getName() : null)
                .amount(link.getAmount())
                .partnerUserId(link.getPartnerUserId())
                .partnerReference(link.getPartnerReference())
                .status(link.getStatus().name())
                .isUsable(isUsable)
                .expiresAt(link.getExpiresAt())
                .createdAt(link.getCreatedAt());

        if (isUsable && partner != null) {
            // WidgetSessionData 토큰 발급 — TORQ 스코프
            try {
                WidgetSessionData sessionData = new WidgetSessionData(
                        partner.getId(),
                        partner.getPartnerCode(),
                        partner.getName(),
                        link.getPartnerUserId(),
                        Set.of("TORQ"));
                builder.accessToken(tokenHandler.generateAccessToken(sessionData));
            } catch (Exception e) {
                log.warn("TORQ 링크 Widget 토큰 생성 실패: linkCode={}, error={}", linkCode, e.getMessage());
            }
        }

        log.info("TORQ 링크 정보 조회: linkCode={}, partnerId={}, isUsable={}",
                linkCode, link.getPartnerId(), isUsable);
        return builder.build();
    }

    /**
     * TORQ 링크 상태 조회 (폴링용).
     *
     * @param linkCode 링크 코드
     * @return 링크 상태 + torqTradeId
     * @response 200 조회 성공
     * @response 404 링크 없음
     */
    @GetMapping(name = "TORQ 링크 상태 조회", value = "/{linkCode}/status")
    public TorqLinkStatusResponse getLinkStatus(@PathVariable String linkCode) {
        TorqLink link = torqLinkRepository.findByLinkCode(linkCode);
        if (link == null) {
            throw new NotFoundException(ErrorCodes.TORQ_LINK_NOT_FOUND);
        }

        autoExpireIfNeeded(link);

        return TorqLinkStatusResponse.builder()
                .status(link.getStatus().name())
                .torqTradeId(link.getTorqTradeId())
                .isUsable(link.getStatus() == TorqLinkStatus.ACTIVE)
                .build();
    }

    // ── helpers ──

    /** ACTIVE 링크의 expiresAt이 지났으면 EXPIRED로 자동 전환 */
    private void autoExpireIfNeeded(TorqLink link) {
        if (link.getStatus() == TorqLinkStatus.ACTIVE
                && link.getExpiresAt() != null
                && link.getExpiresAt().isBefore(LocalDateTime.now())) {
            link.setStatus(TorqLinkStatus.EXPIRED);
            torqLinkRepository.modify(link);
        }
    }
}
```

---

## 9. open-api DTO

### 9-1. `TorqLinkInfoResponse.java`

**파일**: `open-api/src/main/java/com/cryptoments/openapi/dto/response/TorqLinkInfoResponse.java`

```java
package com.cryptoments.openapi.dto.response;

import lombok.*;
import one.axim.gradle.annotation.XSample;

import java.time.LocalDateTime;

/**
 * TORQ 링크 정보 응답 (Widget 진입 시 공개 API).
 *
 * <p>인증 없이 호출되며, 링크가 사용 가능(ACTIVE) 상태일 때만
 * {@code accessToken} 필드가 채워진다.
 * PhonepayLinkInfoResponse보다 단순 — Axim/eKYC 불필요.
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class TorqLinkInfoResponse {

    /** 링크 코드 */
    @XSample("tlnk_a1b2c3d4e5f6g7h8")
    private String linkCode;

    /** 파트너 이름 (UI 표시용) */
    @XSample("크립토먼츠")
    private String partnerName;

    /** KRW 금액. null이면 고객이 직접 입력 */
    @XSample("50000")
    private Long amount;

    /** 파트너 사용자 ID */
    @XSample("user_42")
    private String partnerUserId;

    /** 파트너 참조 코드 (주문번호 등) */
    @XSample("ORDER-2026-001")
    private String partnerReference;

    /** 링크 상태 (ACTIVE / USED / EXPIRED / CANCELLED / DEACTIVATED) */
    @XSample("ACTIVE")
    private String status;

    /** 사용 가능 여부 */
    @XSample("true")
    private Boolean isUsable;

    /** 만료 시각 */
    private LocalDateTime expiresAt;

    /** 생성 시각 */
    private LocalDateTime createdAt;

    /** Widget 세션 토큰 (사용 가능 링크에서만 발급) */
    private String accessToken;
}
```

### 9-2. `TorqLinkStatusResponse.java`

**파일**: `open-api/src/main/java/com/cryptoments/openapi/dto/response/TorqLinkStatusResponse.java`

```java
package com.cryptoments.openapi.dto.response;

import lombok.*;
import one.axim.gradle.annotation.XSample;

/**
 * TORQ 링크 상태 응답 (Widget 폴링용).
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class TorqLinkStatusResponse {

    /** 링크 상태 */
    @XSample("USED")
    private String status;

    /** TORQ 거래 ID (USED 상태일 때 채워짐) */
    @XSample("123")
    private Long torqTradeId;

    /** 사용 가능 여부 */
    @XSample("false")
    private Boolean isUsable;
}
```

---

## 10. Widget UI 변경 — `torq.vue`

### 10-1. 라우터 변경

**파일**: `widget-ui/src/router/index.js`

기존 `/torq` 라우트를 linkCode 파라미터 지원으로 변경:

```js
// 변경 전:
{
  path: "/torq",
  component: () => import("../views/torq.vue"),
},

// 변경 후 — 두 라우트 모두 등록 (query param 방식 + path param 방식):
{
  path: "/torq",
  component: () => import("../views/torq.vue"),
},
{
  path: "/torq/:linkCode",
  component: () => import("../views/torq.vue"),
},
```

### 10-2. Widget API 추가

**파일**: `widget-ui/src/api/widgetApis.js`

TORQ 섹션 하단에 추가:

```js
// ── TORQ Link (Public — 인증 불필요) ──

/** TORQ 링크 정보 조회 (Public). linkCode로 링크 정보 + accessToken 획득 */
export async function getTorqLinkInfoApi(linkCode) {
  const response = await Rest.axios('get', `/widgets/torq/links/${linkCode}`)
  return response
}

/** TORQ 링크 상태 조회 (Public, 폴링용) */
export async function getTorqLinkStatusApi(linkCode) {
  const response = await Rest.axios('get', `/widgets/torq/links/${linkCode}/status`)
  return response
}
```

### 10-3. `torq.vue` onMounted 변경

현재 torq.vue는 URL query parameter(token, presetAmount 등)로만 동작한다.
linkCode가 있으면 open-api에서 링크 정보를 가져오고, 토큰을 세팅한 후 기존 플로우로 진입해야 한다.

**변경 내용** — `<script setup>` 블록:

```js
// 1. import 추가 (기존 import 라인에 추가)
import { useRoute } from 'vue-router'
import { getTorqLinkInfoApi, getTorqLinkStatusApi } from '@/api/widgetApis'

// 2. route 추가
const route = useRoute()

// 3. 링크 상태 ref 추가
const linkResp = ref(null)  // TORQ 링크 응답 캐시

// 4. onMounted 교체
onMounted(async () => {
  params.value = parseUrlParams()

  // ── Case A: linkCode 경로 (TORQ Link URL) ──
  const linkCode = route.params.linkCode
  if (linkCode && typeof linkCode === 'string' && !linkCode.startsWith(':')) {
    store.setIsLoading(true)
    try {
      const resp = await getTorqLinkInfoApi(linkCode)
      linkResp.value = resp.data

      if (!resp.data || !resp.data.isUsable) {
        // 만료/취소/사용완료 링크
        currentStep.value = 'torq-failed'
        torqError.value = resp.data?.status === 'EXPIRED' ? '만료된 링크입니다.'
            : resp.data?.status === 'USED' ? '이미 사용된 링크입니다.'
            : resp.data?.status === 'CANCELLED' ? '취소된 링크입니다.'
            : '사용할 수 없는 링크입니다.'
        sendMessageToParent('WIDGET_ERROR', { message: torqError.value })
        return
      }

      // accessToken 세팅
      if (resp.data.accessToken) {
        session.setAccessToken(resp.data.accessToken)
      }

      // 금액 설정
      if (resp.data.amount && resp.data.amount > 0) {
        krwAmount.value = resp.data.amount
        params.value.presetAmount = resp.data.amount
        params.value.allowAmountEdit = false  // 링크 금액 고정
        fetchTorqQuote()
      } else {
        // 금액 미지정 링크 → 고객이 입력
        currentStep.value = 'amount'
      }
    } catch (e) {
      currentStep.value = 'torq-failed'
      torqError.value = '링크 정보를 가져올 수 없습니다.'
      sendMessageToParent('WIDGET_ERROR', { message: e?.message })
      return
    } finally {
      store.setIsLoading(false)
    }

    sendMessageToParent('WIDGET_READY', { widgetType: 'torq-link', linkCode })
    document.addEventListener('visibilitychange', handleVisibilityChange)
    return
  }

  // ── Case B: 기존 query param 방식 (deposit.vue에서 진입) ──
  if (params.value.token) {
    session.setAccessToken(params.value.token)
  }

  if (params.value.presetAmount && Number(params.value.presetAmount) > 0) {
    krwAmount.value = Number(params.value.presetAmount)
    fetchTorqQuote()
  } else if (params.value.krwAmount && Number(params.value.krwAmount) > 0) {
    krwAmount.value = Number(params.value.krwAmount)
    fetchTorqQuote()
  } else {
    currentStep.value = 'amount'
  }

  sendMessageToParent('WIDGET_READY')
  document.addEventListener('visibilitychange', handleVisibilityChange)
})
```

---

## 11. TORQ 거래 완료 시 링크 상태 업데이트

TORQ 거래가 COMPLETED 되면 `torq_links.status`를 `USED`로, `torq_trade_id`를 연결해야 한다.

**위치**: `open-api` 모듈의 기존 TORQ Webhook 처리 또는 거래 완료 로직

기존 `TorqService` (또는 `TorqWidgetController`의 거래 생성 부분)에서 거래 생성 시 `linkCode`를 받아서 연결하는 방식:

### 11-1. 거래 생성 시 linkCode 연결

`torq_trades` 테이블에 `link_code` 컬럼 추가 (선택사항) 또는 Widget에서 거래 생성 시 linkCode를 전달:

**방법 A (권장)**: Widget에서 거래 생성 API 호출 시 linkCode를 함께 전달

```
POST /widgets/api/torq/trades
{
  "krwAmount": "50000",
  "linkCode": "tlnk_a1b2c3d4e5f6g7h8"   ← 추가
}
```

백엔드 `TorqWidgetController.createTrade()` 에서:
1. linkCode가 있으면 torq_links 조회 → ACTIVE 확인
2. 거래 생성 후 `torq_links.torq_trade_id` = 생성된 거래 ID, `status` = `USED` 로 업데이트

**방법 B**: TORQ Webhook에서 거래 COMPLETED 시 torq_trade_id로 역추적 → 링크 USED 전환

→ **방법 A 권장** (거래 시작 시점에 즉시 링크 소비)

### 11-2. TorqWidgetController 수정 사항

기존 `TorqWidgetController.createTrade()` 메서드에 추가:

```java
// createTrade 요청 DTO에 linkCode 필드 추가
private String linkCode;  // nullable — 링크 경유 시에만 전달

// createTrade 메서드 내부 — 거래 생성 성공 후:
if (request.getLinkCode() != null) {
    TorqLink link = torqLinkRepository.findByLinkCode(request.getLinkCode());
    if (link != null && link.getStatus() == TorqLinkStatus.ACTIVE) {
        link.setStatus(TorqLinkStatus.USED);
        link.setTorqTradeId(savedTradeId);
        torqLinkRepository.modify(link);
    }
}
```

### 11-3. Widget torq.vue 수정 — 거래 생성 시 linkCode 전달

`startTorqTrade()` 함수에서 linkCode가 있으면 payload에 추가:

```js
const startTorqTrade = async () => {
  if (!torqQuote.value) return
  store.setIsLoading(true)
  torqError.value = null
  try {
    const payload = {
      krwAmount: String(torqQuote.value.krwAmount),
    }
    // 링크 경유 시 linkCode 전달
    const linkCode = route.params.linkCode
    if (linkCode) {
      payload.linkCode = linkCode
    }
    const res = await createTorqTradeApi(payload)
    // ... 기존 로직
  }
  // ...
}
```

---

## 12. 구현 체크리스트

### Phase 1: IntelliJ (Spring Boot) — 7개 파일

| # | 모듈 | 파일 | 비고 |
|---|------|------|------|
| 1 | common | `enums/TorqLinkStatus.java` | §2 |
| 2 | common | `entity/TorqLink.java` | §3 |
| 3 | common | `repository/TorqLinkRepository.java` | §4 |
| 4 | common | `exception/ErrorCodes.java` | §5 — 3개 상수 추가 |
| 5 | partner-api | `controller/PartnerTorqLinkController.java` | §6 |
| 6 | partner-api | `dto/request/CreateTorqLinkRequest.java` | §7-1 |
| 7 | partner-api | `dto/response/TorqLinkResponse.java` | §7-2 |

### Phase 2: IntelliJ (Spring Boot) — 3개 파일

| # | 모듈 | 파일 | 비고 |
|---|------|------|------|
| 8 | open-api | `controller/widget/TorqLinkWidgetController.java` | §8 |
| 9 | open-api | `dto/response/TorqLinkInfoResponse.java` | §9-1 |
| 10 | open-api | `dto/response/TorqLinkStatusResponse.java` | §9-2 |

### Phase 3: Cowork (Widget UI) — 3개 파일 직접 수정

| # | 파일 | 비고 |
|---|------|------|
| 11 | `widget-ui/src/router/index.js` | §10-1 — `/torq/:linkCode` 라우트 추가 |
| 12 | `widget-ui/src/api/widgetApis.js` | §10-2 — getTorqLinkInfoApi, getTorqLinkStatusApi 추가 |
| 13 | `widget-ui/src/views/torq.vue` | §10-3 — onMounted linkCode 분기 + §11-3 startTorqTrade linkCode 전달 |

### Phase 4: 기존 코드 수정

| # | 파일 | 비고 |
|---|------|------|
| 14 | `open-api/.../TorqWidgetController.java` | §11-2 — createTrade에 linkCode 처리 추가 |

### DDL

| # | 작업 | 비고 |
|---|------|------|
| 15 | `v2-docs/CRYPTOMENTS_V2_DDL.sql` | §1 — torq_links 테이블 추가 |
| 16 | 운영 DB 적용 | §1 — CREATE TABLE 실행 |

---

## 13. 테스트 시나리오

### 13-1. partner-api 링크 생성

```bash
# 1. 파트너 인증
TOKEN=$(curl -s -X POST https://partner.cryptoments.cc/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"apiKey":"...","apiSecret":"..."}' | jq -r '.accessToken')

# 2. TORQ 링크 생성
curl -X POST https://partner.cryptoments.cc/api/v1/torq/links \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "amount": 50000,
    "partnerUserId": "test_user_1",
    "partnerReference": "ORDER-001",
    "expiresInMinutes": 60
  }'
# 응답: { "linkCode": "tlnk_xxx", "linkUrl": "https://widget.cryptoments.cc/torq/tlnk_xxx", ... }

# 3. 링크 URL 접속 → torq.vue 진입 → 자동 견적 조회
```

### 13-2. Widget 동작 흐름

```
1. 고객이 https://widget.cryptoments.cc/torq/tlnk_xxx 접속
2. torq.vue onMounted → route.params.linkCode = "tlnk_xxx"
3. GET /widgets/torq/links/tlnk_xxx → 링크 정보 + accessToken
4. session.setAccessToken(accessToken) → 이후 API 호출에 Bearer 토큰 자동 부착
5. amount=50000 → krwAmount 세팅 → fetchTorqQuote() 자동 호출
6. 기존 TORQ 플로우 진행 (견적 → 거래 생성 → 입금 → 확인 → 완료)
7. 거래 생성 시 linkCode 전달 → torq_links.status = USED
```
