Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ List<DailyClassificationTargetTransaction> findUnanalyzedTransactions(
int limit
);

/** 특정 사용자의 아직 분석되지 않은 일간 소비 분석 대상 거래를 조회합니다. */
List<DailyClassificationTargetTransaction> findUnanalyzedTransactionsByUserId(
Long userId,
int limit
);

/**
* 일간 소비 분류 결과를 TXN_ANALYSIS에 저장합니다.
*
Expand Down Expand Up @@ -57,4 +63,4 @@ List<ClassificationTargetTransaction> findClassificationTargets(
void saveTransactionAnalyses(
TransactionAnalysisSaveRequest request
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ntropy.common.client;

/** 계좌 거래 수집 완료 후 사용자 단위 소비 분류를 실행하는 내부 계약입니다. */
public interface TransactionClassificationCommandClient {

/** 특정 사용자의 아직 분석되지 않은 모든 거래를 분류합니다. */
int classifyUnanalyzedTransactions(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ public class LocalAccountTransactionAnalysisClient
return txnAnalysisService.findUnanalyzedTransactions(limit);
}

@Override
public List<DailyClassificationTargetTransaction>
findUnanalyzedTransactionsByUserId(Long userId, int limit) {
return txnAnalysisService.findUnanalyzedTransactionsByUserId(userId, limit);
}

/**
* 일간 배치에서 생성한 소비·비소비 분석 결과를 저장합니다.
*/
Expand Down Expand Up @@ -62,4 +68,4 @@ public void saveTransactionAnalyses(
) {
txnAnalysisService.saveAnalyses(request);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.ntropy.account.service.VirtualFinancialDataService;
import com.ntropy.account.service.VirtualFinancialDataService.GenerationSummary;
import com.ntropy.common.client.FinancialAccountCommandClient;
import com.ntropy.common.client.TransactionClassificationCommandClient;
import com.ntropy.common.dto.account.AccountRegistrationCommand;
import com.ntropy.common.dto.account.AccountRegistrationSummary;
import com.ntropy.common.dto.account.BankSummary;
Expand All @@ -44,6 +45,7 @@ public class LocalFinancialAccountCommandClient implements FinancialAccountComma
private final AccountLifecycleMapper accountLifecycleMapper;
private final AccountMapper accountMapper;
private final CodefConnectionMapper codefConnectionMapper;
private final TransactionClassificationCommandClient transactionClassificationCommandClient;

@Override
public List<BankSummary> findSupportedBanks() {
Expand Down Expand Up @@ -80,6 +82,7 @@ public AccountRegistrationSummary registerAccount(Long userId, AccountRegistrati

if ("VIRTUAL".equals(connectionType)) {
GenerationSummary summary = virtualAccountRegenerationService.regenerateForUser(userId, bank);
classifyTransactionsSafely(userId);
return new AccountRegistrationSummary(connectionType, bank.getOrganizationCode(), summary.accounts());
}

Expand All @@ -94,9 +97,19 @@ public AccountRegistrationSummary registerAccount(Long userId, AccountRegistrati
);
ensureVirtualDatasetSafely(userId, bank);
int accountCount = accountCollectionService.collect(userId, bank, birthDate, startDate, endDate).size();
classifyTransactionsSafely(userId);
return new AccountRegistrationSummary(connectionType, bank.getOrganizationCode(), accountCount);
}

private void classifyTransactionsSafely(Long userId) {
try {
int processed = transactionClassificationCommandClient.classifyUnanalyzedTransactions(userId);
log.info("계좌 연동 후 소비 분류 완료: userId={}, processed={}", userId, processed);
} catch (RuntimeException e) {
log.warn("계좌 연동 후 소비 분류 실패: userId={}", userId, e);
}
}

private void ensureVirtualDatasetSafely(Long userId, PersonalBank bank) {
try {
if (accountMapper.existsAnyByUserIdAndProvider(userId, ConnectionProvider.NTROPY.name())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,9 @@ List<Long> findValidTransactionIds(
List<DailyClassificationTargetTransaction> findUnanalyzedTransactions(
@Param("limit") int limit
);

List<DailyClassificationTargetTransaction> findUnanalyzedTransactionsByUserId(
@Param("userId") Long userId,
@Param("limit") int limit
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,28 @@ public class TxnAnalysisService {
*/
public List<DailyClassificationTargetTransaction>
findUnanalyzedTransactions(int limit) {
validateDailyQueryLimit(limit);

return financialDataQueryMapper.findUnanalyzedTransactions(limit);
}

/** 특정 사용자의 아직 분석되지 않은 일간 분석 대상 거래를 조회합니다. */
public List<DailyClassificationTargetTransaction>
findUnanalyzedTransactionsByUserId(Long userId, int limit) {
if (userId == null || userId <= 0) {
throw new ServiceException(AccountErrorCode.INVALID_REQUEST, "userId는 양수여야 합니다.");
}
validateDailyQueryLimit(limit);
return financialDataQueryMapper.findUnanalyzedTransactionsByUserId(userId, limit);
}

private void validateDailyQueryLimit(int limit) {
if (limit <= 0 || limit > MAX_DAILY_QUERY_SIZE) {
throw new ServiceException(
AccountErrorCode.INVALID_REQUEST,
"limit은 1~500이어야 합니다."
);
}

return financialDataQueryMapper.findUnanalyzedTransactions(limit);
}

/**
Expand Down Expand Up @@ -207,4 +220,4 @@ public void saveAnalyses(

txnAnalysisMapper.upsertAnalyses(request);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,48 @@
LIMIT #{limit}
</select>

<select id="findUnanalyzedTransactionsByUserId"
resultType="com.ntropy.common.dto.account.DailyClassificationTargetTransaction">
SELECT
transaction_row.account_transaction_id AS transactionId,
account_row.user_id AS userId,
transaction_row.transaction_category AS transactionCategory,
CAST(transaction_row.out_amount AS SIGNED) AS outAmount,
CAST(transaction_row.in_amount AS SIGNED) AS inAmount,
account_row.organization_code AS organizationCode,
transaction_row.loan_transaction_type_name AS loanTransactionTypeName,
transaction_row.desc1,
transaction_row.desc2,
transaction_row.desc3,
transaction_row.desc4
FROM ACCOUNT_TRANSACTION transaction_row
INNER JOIN ACCOUNT account_row
ON account_row.account_id = transaction_row.account_id
WHERE account_row.user_id = #{userId}
AND NOT EXISTS (
SELECT 1
FROM TXN_ANALYSIS analysis_row
WHERE analysis_row.account_transaction_id
= transaction_row.account_transaction_id
)
AND (
(
transaction_row.transaction_category = 'ORDINARY'
AND transaction_row.out_amount &gt; 0
)
OR (
transaction_row.transaction_category = 'INSTALLMENT'
AND transaction_row.in_amount &gt; 0
)
OR (
transaction_row.transaction_category = 'LOAN'
AND transaction_row.out_amount &gt; 0
)
)
ORDER BY transaction_row.account_transaction_id
LIMIT #{limit}
</select>


<sql id="accountColumns">
account_row.account_id AS id, account_row.codef_connection_id AS codefConnectionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,5 +256,11 @@ public List<Long> findValidTransactionIds(
findUnanalyzedTransactions(int limit) {
return List.of();
}

@Override
public List<DailyClassificationTargetTransaction>
findUnanalyzedTransactionsByUserId(Long userId, int limit) {
return List.of();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.ntropy.account.service.VirtualFinancialDataService;
import com.ntropy.account.service.VirtualFinancialDataService.GenerationSummary;
import com.ntropy.common.dto.account.AccountRegistrationCommand;
import com.ntropy.common.client.TransactionClassificationCommandClient;
import com.ntropy.common.exception.ServiceException;

class LocalFinancialAccountCommandClientTest {
Expand Down Expand Up @@ -135,6 +136,44 @@ collectionService, new StubVirtualAccountRegenerationService(),
assertEquals("19900101", collectionService.lastBirthDate);
}

@Test
void classifiesUsersTransactionsAfterCodefCollectionCompletes() {
StubTransactionClassificationCommandClient classificationClient =
new StubTransactionClassificationCommandClient();
LocalFinancialAccountCommandClient client = newClient(
new StubPersonalBankAccountService(), new StubAccountCollectionService(),
new StubVirtualAccountRegenerationService(), new StubVirtualFinancialDataService(),
new StubAccountLifecycleMapper(1, 1), new StubAccountMapper(false),
new StubCodefConnectionMapper(), classificationClient
);

client.registerAccount(
42L, new AccountRegistrationCommand("CODEF", "0088", "bank-id", "bank-password", null)
);

assertEquals(List.of(42L), classificationClient.userIds);
}

@Test
void keepsAccountRegistrationSuccessfulWhenClassificationFails() {
StubTransactionClassificationCommandClient classificationClient =
new StubTransactionClassificationCommandClient();
classificationClient.failure = new IllegalStateException("분류 실패");
LocalFinancialAccountCommandClient client = newClient(
new StubPersonalBankAccountService(), new StubAccountCollectionService(),
new StubVirtualAccountRegenerationService(), new StubVirtualFinancialDataService(),
new StubAccountLifecycleMapper(1, 1), new StubAccountMapper(false),
new StubCodefConnectionMapper(), classificationClient
);

var result = client.registerAccount(
42L, new AccountRegistrationCommand("VIRTUAL", "0088", null, null, null)
);

assertEquals("VIRTUAL", result.connectionType());
assertEquals(List.of(42L), classificationClient.userIds);
}

@Test
void registersIndustrialBankWithValidBirthDate() {
StubAccountCollectionService collectionService = new StubAccountCollectionService();
Expand Down Expand Up @@ -418,13 +457,45 @@ private static LocalFinancialAccountCommandClient newClient(
AccountLifecycleMapper lifecycleMapper,
AccountMapper accountMapper,
CodefConnectionMapper connectionMapper
) {
return newClient(
personalBankAccountService, collectionService, regenerationService,
virtualFinancialDataService, lifecycleMapper, accountMapper, connectionMapper,
new StubTransactionClassificationCommandClient()
);
}

private static LocalFinancialAccountCommandClient newClient(
PersonalBankAccountService personalBankAccountService,
AccountCollectionService collectionService,
VirtualAccountRegenerationService regenerationService,
VirtualFinancialDataService virtualFinancialDataService,
AccountLifecycleMapper lifecycleMapper,
AccountMapper accountMapper,
CodefConnectionMapper connectionMapper,
TransactionClassificationCommandClient classificationClient
) {
return new LocalFinancialAccountCommandClient(
personalBankAccountService, collectionService, regenerationService, virtualFinancialDataService,
lifecycleMapper, accountMapper, connectionMapper
lifecycleMapper, accountMapper, connectionMapper, classificationClient
);
}

private static class StubTransactionClassificationCommandClient
implements TransactionClassificationCommandClient {
private final List<Long> userIds = new ArrayList<>();
private RuntimeException failure;

@Override
public int classifyUnanalyzedTransactions(Long userId) {
userIds.add(userId);
if (failure != null) {
throw failure;
}
return 0;
}
}

private static class StubPersonalBankAccountService extends PersonalBankAccountService {
private final List<String> callOrder;
private RuntimeException failure;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,5 +398,11 @@ public List<Long> findValidTransactionIds(Long userId, String yearMonth, List<Lo
findUnanalyzedTransactions(int limit) {
return List.of();
}

@Override
public List<DailyClassificationTargetTransaction>
findUnanalyzedTransactionsByUserId(Long userId, int limit) {
return List.of();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;

import org.springframework.stereotype.Service;

Expand All @@ -15,6 +16,7 @@
import com.ntropy.ai.dto.fastapi.TransactionClassificationResult;
import com.ntropy.ai.dto.fastapi.TransactionForClassification;
import com.ntropy.common.client.AccountTransactionAnalysisClient;
import com.ntropy.common.client.TransactionClassificationCommandClient;
import com.ntropy.common.dto.account.DailyClassificationTargetTransaction;
import com.ntropy.common.dto.account.TransactionAnalysisSaveItem;

Expand All @@ -28,7 +30,7 @@
@Slf4j
@Service
@RequiredArgsConstructor
public class DailyTransactionClassificationService {
public class DailyTransactionClassificationService implements TransactionClassificationCommandClient {

private static final int DB_PAGE_SIZE = 500;
private static final int FAST_API_BATCH_SIZE = 100;
Expand Down Expand Up @@ -70,12 +72,27 @@ public class DailyTransactionClassificationService {
* @return 저장한 전체 거래 분석 결과 수
*/
public int run() {
return runPages(() -> accountTransactionAnalysisClient
.findUnanalyzedTransactions(DB_PAGE_SIZE));
}

/** 계좌 연동을 마친 특정 사용자의 미분류 거래만 즉시 처리합니다. */
@Override
public int classifyUnanalyzedTransactions(Long userId) {
if (userId == null || userId <= 0) {
throw new IllegalArgumentException("userId는 양수여야 합니다.");
}
return runPages(() -> accountTransactionAnalysisClient
.findUnanalyzedTransactionsByUserId(userId, DB_PAGE_SIZE));
}

private int runPages(
Supplier<List<DailyClassificationTargetTransaction>> targetSupplier
) {
int totalProcessed = 0;

while (true) {
List<DailyClassificationTargetTransaction> targets =
accountTransactionAnalysisClient
.findUnanalyzedTransactions(DB_PAGE_SIZE);
List<DailyClassificationTargetTransaction> targets = targetSupplier.get();

if (targets == null || targets.isEmpty()) {
return totalProcessed;
Expand Down Expand Up @@ -313,4 +330,4 @@ private TransactionAnalysisSaveItem fallback(
"VARIABLE"
);
}
}
}
Loading
Loading