diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 47097eb54..796d6ddee 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -187,14 +187,14 @@ public class FinanceService { for (var entry : byBill.entrySet()) { BigDecimal applied = entry.getValue(); jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId); - jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, applied, operatorId, entry.getKey(), tenantId); + jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, operatorId, entry.getKey(), tenantId); Integer settlementCount = jdbc.queryForObject( "SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class, tenantId, entry.getKey()); if (settlementCount != null && settlementCount > 0) { int settlementUpdated = jdbc.update( - "UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?", - applied, applied, applied, applied, operatorId, tenantId, entry.getKey(), applied); + "UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?", + applied, applied, operatorId, tenantId, entry.getKey(), applied); if (settlementUpdated != 1) throw new BizException(ResultCode.CONFLICT, "应收结算单状态或余额已变化"); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 0dadafc8e..4e84982b1 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -201,6 +201,9 @@ public class FinanceSettlementService { "SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version " + "FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId); long customerId = number(settlement, "tenant_id").longValue(); + Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST", + "/api/platform/finance/v1/settlements/" + settlementId + "/transactions", request); + if (replay != null) return getSettlement(replay); if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "仅应付结算单可登记付款"); if (!List.of("ISSUED", "PARTIALLY_PAID").contains(text(settlement, "status"))) { throw error("FIN_409", "结算单状态不允许付款"); @@ -209,9 +212,6 @@ public class FinanceSettlementService { if (number(settlement, "version").intValue() != request.expectedVersion()) throw error("FIN_409", "结算单版本冲突"); BigDecimal outstanding = decimal(settlement, "outstanding_amount"); if (request.amount().compareTo(outstanding) > 0) throw error("FIN_409", "付款金额不得超过未付余额"); - Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST", - "/api/platform/finance/v1/settlements/" + settlementId + "/transactions", request); - if (replay != null) return getSettlement(settlementId); validateVoucherFiles(customerId, request.voucherFileIds()); insertPayment(customerId, settlementId, request, operatorId); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index 3a8be9b06..b52559300 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -126,9 +126,11 @@ class FinanceServiceValidationTest { service.createReceipt(9L, 2L, request, "receipt-key"); - verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount="), - eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), - eq(new BigDecimal("40.00")), eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00"))); + verify(jdbc).update(contains("payment_status=CASE WHEN outstanding_amount=0"), + eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), + eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00"))); + verify(jdbc).update(contains("status=CASE WHEN outstanding_amount=0"), + eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(2L), eq(50L), eq(9L)); } @Test diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 359b8eb4c..dd0e29726 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -39,6 +39,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.startsWith; import org.mockito.ArgumentCaptor; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.RETURNS_DEFAULTS; @@ -276,6 +277,47 @@ class FinanceSettlementBehaviorTest { assertThat(result.summary().outstandingAmount()).isZero(); } + @Test + void finalPaymentReplayReturnsFirstResultWithoutDuplicatePayment() { + JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> + "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); + FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class); + Map before = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"), + new BigDecimal("60.00"), 1); + Map after = settlementRow("PAYABLE", "PAID", new BigDecimal("100.00"), BigDecimal.ZERO, 2); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))) + .thenReturn(List.of(before), List.of(after)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of()); + stubSettlementDetail(jdbc, after); + when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L))); + FinanceSettlementService service = service(jdbc, fileClient); + + SettlementDetail first = service.createSettlementPayment(88L, 7L, "final-replay-key", + payment(new BigDecimal("60.00"), 1)); + ArgumentCaptor requestHash = ArgumentCaptor.forClass(String.class); + verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"), requestHash.capture()); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), + eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of(Map.of( + "request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L, + "updated_at", LocalDateTime.now()))); + + SettlementDetail replay = service.createSettlementPayment(88L, 7L, "final-replay-key", + payment(new BigDecimal("60.00"), 1)); + + assertThat(first.summary().status()).isEqualTo("PAID"); + assertThat(replay.summary().status()).isEqualTo("PAID"); + assertThat(replay.summary().outstandingAmount()).isZero(); + long paymentInserts = org.mockito.Mockito.mockingDetails(jdbc).getInvocations().stream() + .filter(invocation -> invocation.getMethod().getName().equals("update") + && invocation.getArguments().length > 0 + && String.valueOf((Object) invocation.getArgument(0)).startsWith("INSERT INTO fin_settlement_payment")) + .count(); + assertThat(paymentInserts).isEqualTo(1); + } + @Test void receivableSettlementDetailIncludesAppliedReceiptHistory() { JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class); diff --git a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java new file mode 100644 index 000000000..5f51e8735 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java @@ -0,0 +1,38 @@ +package com.baiyue.iam.notification.dto; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public final class NotificationDtos { + private NotificationDtos() { } + + public enum RecipientType { USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE } + + public record CreateNotificationRequest( + @NotNull Long tenantId, + @NotBlank @Size(max = 64) String templateCode, + @NotNull RecipientType recipientType, + @Size(max = 1000) List recipientIds, + @Size(max = 64) String roleCode, + @NotNull @Size(max = 32) Map variables, + @Future LocalDateTime scheduledAt, + @NotBlank @Size(min = 8, max = 128) String idempotencyKey, + @Size(max = 128) String sourceEventId) { } + + public record NotificationSummary(Long id, String category, String title, String status, + LocalDateTime sentAt, LocalDateTime readAt) { } + + public record NotificationDetail(Long id, String category, String title, String body, + String status, String sourceType, String sourceId, + LocalDateTime sentAt, LocalDateTime readAt) { } + + public record NotificationTaskResponse(Long id, String taskNo, String status, int recipientCount, + LocalDateTime scheduledAt, LocalDateTime sentAt, + boolean duplicated) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationInbox.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationInbox.java new file mode 100644 index 000000000..94959aa38 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationInbox.java @@ -0,0 +1,29 @@ +package com.baiyue.iam.notification.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter @Setter +@TableName("iam_notification_inbox") +public class NotificationInbox { + @TableId private Long id; + private Long taskId; + private Long recipientId; + private Long tenantId; + private Long membershipId; + private String appCode; + private String category; + private String title; + private String body; + private String sourceType; + private String sourceId; + private String status; + private LocalDateTime sentAt; + private LocalDateTime readAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationRecipient.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationRecipient.java new file mode 100644 index 000000000..83f508ccf --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationRecipient.java @@ -0,0 +1,26 @@ +package com.baiyue.iam.notification.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter @Setter +@TableName("iam_notification_recipient") +public class NotificationRecipient { + @TableId private Long id; + private Long taskId; + private Long tenantId; + private Long membershipId; + private Long userId; + private String displayName; + private String appCode; + private String channel; + private String status; + private String failureReason; + private LocalDateTime deliveredAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java new file mode 100644 index 000000000..0dfd51b06 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTask.java @@ -0,0 +1,32 @@ +package com.baiyue.iam.notification.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter @Setter +@TableName("iam_notification_task") +public class NotificationTask { + @TableId private Long id; + private String taskNo; + private Long tenantId; + private Long templateId; + private String templateCode; + private String recipientType; + private String variablesJson; + private String idempotencyKey; + private String sourceEventId; + private String sourceEventType; + private String status; + private Integer recipientCount; + private Integer attemptCount; + private LocalDateTime scheduledAt; + private LocalDateTime sentAt; + private String lastError; + private Long createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTemplate.java b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTemplate.java new file mode 100644 index 000000000..e772f9a4e --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/entity/NotificationTemplate.java @@ -0,0 +1,28 @@ +package com.baiyue.iam.notification.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter @Setter +@TableName("iam_notification_template") +public class NotificationTemplate { + @TableId private Long id; + private Long tenantId; + private String templateCode; + private String templateName; + private String category; + private String channel; + private String titleTemplate; + private String bodyTemplate; + private String variableWhitelistJson; + private String status; + private Integer version; + private Long createdBy; + private Long updatedBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationInboxMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationInboxMapper.java new file mode 100644 index 000000000..7bc70d115 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationInboxMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.iam.notification.mapper; +import com.baiyue.iam.notification.entity.NotificationInbox; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface NotificationInboxMapper extends BaseMapper { } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationRecipientMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationRecipientMapper.java new file mode 100644 index 000000000..5b002edc2 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationRecipientMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.iam.notification.mapper; +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface NotificationRecipientMapper extends BaseMapper { } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java new file mode 100644 index 000000000..570a06f19 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.iam.notification.mapper; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface NotificationTaskMapper extends BaseMapper { } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTemplateMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTemplateMapper.java new file mode 100644 index 000000000..588e15664 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTemplateMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.iam.notification.mapper; +import com.baiyue.iam.notification.entity.NotificationTemplate; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface NotificationTemplateMapper extends BaseMapper { } diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml new file mode 100644 index 000000000..66054010a --- /dev/null +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -0,0 +1,113 @@ +openapi: 3.0.3 +info: + title: IAM Notification Center API + version: 1.0.0 + description: First release is in-app notifications only. There is no clear-read/delete operation in this release. +servers: + - url: /api +tags: + - name: Notification inbox + - name: Platform notification administration +paths: + /{app}/v1/notifications: + get: + tags: [Notification inbox] + summary: List current user's inbox + parameters: + - {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}} + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: status, in: query, schema: {type: string, enum: [UNREAD, READ]}} + - {name: category, in: query, schema: {type: string, maxLength: 64}} + responses: + '200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxPageResponse'}}}} + /{app}/v1/notifications/unread-count: + get: + tags: [Notification inbox] + summary: Count current user's unread inbox messages + parameters: + - {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}} + responses: + '200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/UnreadCountResponse'}}}} + /{app}/v1/notifications/{id}: + get: + tags: [Notification inbox] + summary: Get one inbox message + parameters: + - {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}} + - {name: id, in: path, required: true, schema: {type: integer, format: int64}} + responses: + '200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxDetailResponse'}}}} + /{app}/v1/notifications/{id}/read: + put: + tags: [Notification inbox] + summary: Mark one message as read (idempotent) + parameters: + - {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}} + - {name: id, in: path, required: true, schema: {type: integer, format: int64}} + responses: + '200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/InboxDetailResponse'}}}} + /{app}/v1/notifications/read-all: + put: + tags: [Notification inbox] + summary: Mark all current-user messages as read (idempotent) + parameters: + - {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}} + responses: + '200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/ReadAllResponse'}}}} + /platform/v1/notifications: + post: + tags: [Platform notification administration] + summary: Create an in-app notification task + responses: + '200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskResponse'}}}} + /platform/v1/notification-templates: + get: + tags: [Platform notification administration] + summary: List templates + parameters: [{name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}, {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}, {name: status, in: query, schema: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}}] + responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplatePageResponse'}}}}} + post: + tags: [Platform notification administration] + summary: Create a DRAFT template + responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateResponse'}}}}} + /platform/v1/notification-templates/{id}: + put: + tags: [Platform notification administration] + summary: Edit a DRAFT template only + responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateResponse'}}}}} + /platform/v1/notification-templates/{id}/status: + put: + tags: [Platform notification administration] + summary: Activate or disable a template; only DRAFT->ACTIVE or ACTIVE->DISABLED + responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateResponse'}}}}} + /platform/v1/notification-tasks: + get: + tags: [Platform notification administration] + summary: List tasks + parameters: [{name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}, {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}, {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}}] + responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskPageResponse'}}}}} + /platform/v1/notification-tasks/{id}/cancel: + put: + tags: [Platform notification administration] + summary: Cancel CREATED or SCHEDULED task + responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskResponse'}}}}} + /platform/v1/notification-tasks/{id}/retry: + put: + tags: [Platform notification administration] + summary: Retry RETRYING or DEAD task + responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskResponse'}}}}} +components: + schemas: + PageMeta: {type: object, required: [page, size, total, pages], properties: {page: {type: integer, format: int64}, size: {type: integer, format: int64}, total: {type: integer, format: int64}, pages: {type: integer, format: int64}}} + InboxItem: {type: object, properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}} + InboxDetail: {allOf: [{$ref: '#/components/schemas/InboxItem'}, {type: object, properties: {body: {type: string}, sourceType: {type: string}, sourceId: {type: string}}}]} + InboxPageResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {records: {type: array, items: {$ref: '#/components/schemas/InboxItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}}} + InboxDetailResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/InboxDetail'}}} + UnreadCountResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {unreadCount: {type: integer, format: int64}}}}} + ReadAllResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {updatedCount: {type: integer, format: int64}}}}} + CreateNotificationRequest: {type: object, required: [tenantId, templateCode, recipientType, variables, idempotencyKey], properties: {tenantId: {type: integer, format: int64, minimum: 0}, templateCode: {type: string, maxLength: 64}, recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]}, recipientIds: {type: array, maxItems: 1000, items: {type: integer, format: int64}}, roleCode: {type: string, maxLength: 64}, variables: {type: object, additionalProperties: {type: string}}, scheduledAt: {type: string, format: date-time, nullable: true}, idempotencyKey: {type: string, minLength: 8, maxLength: 128}, sourceEventId: {type: string, maxLength: 128, nullable: true}}} + TemplateResponse: {type: object, properties: {id: {type: integer, format: int64}, templateCode: {type: string}, templateName: {type: string}, category: {type: string}, channel: {type: string, enum: [IN_APP]}, titleTemplate: {type: string}, bodyTemplate: {type: string}, variableWhitelist: {type: array, items: {type: string}}, status: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}, version: {type: integer}}} + TemplatePageResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {records: {type: array, items: {$ref: '#/components/schemas/TemplateResponse'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}}} + TaskResponse: {type: object, properties: {id: {type: integer, format: int64}, taskNo: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, duplicated: {type: boolean}}} + TaskPageResponse: {type: object, properties: {code: {type: string, example: '200'}, data: {type: object, properties: {records: {type: array, items: {$ref: '#/components/schemas/TaskResponse'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}}}