| 37651 |
vikas |
1 |
package com.spice.profitmandi.service.lms;
|
|
|
2 |
|
|
|
3 |
import com.spice.profitmandi.dao.entity.user.LeadCall;
|
|
|
4 |
import com.spice.profitmandi.dao.repository.dtr.LeadCallRepository;
|
|
|
5 |
import com.spice.profitmandi.service.storage.ObjectStoreService;
|
|
|
6 |
import org.apache.logging.log4j.LogManager;
|
|
|
7 |
import org.apache.logging.log4j.Logger;
|
|
|
8 |
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
9 |
import org.springframework.beans.factory.annotation.Value;
|
|
|
10 |
import org.springframework.stereotype.Service;
|
|
|
11 |
import org.springframework.transaction.annotation.Transactional;
|
|
|
12 |
|
|
|
13 |
import java.util.List;
|
|
|
14 |
|
|
|
15 |
/**
|
|
|
16 |
* Finds call recordings the provider has announced but we have not stored yet, and hands each to
|
|
|
17 |
* {@link RecordingArchiveWorker}. Owning the audio ourselves is what makes the 24-month retention in
|
|
|
18 |
* SOP §17 our promise to keep rather than the vendor's, and what keeps playback working after a
|
|
|
19 |
* provider switch.
|
|
|
20 |
*
|
|
|
21 |
* <p>Deliberately a sweep rather than work done straight off the recording webhook: the webhook fires
|
|
|
22 |
* the moment the call ends, but the provider needs a moment before the file is actually fetchable,
|
|
|
23 |
* and a webhook has to ACK immediately regardless. The sweep doubles as the retry — the work list is
|
|
|
24 |
* simply "rows with a recording handle and no archived document", so nothing is lost across a restart.
|
|
|
25 |
*
|
|
|
26 |
* <p>This class holds no {@code @Scheduled} annotation on purpose. It lives in {@code profitmandi-dao},
|
|
|
27 |
* which is loaded by the fofo WAR, profitmandi-web and profitmandi-cron alike; scheduling it here
|
|
|
28 |
* would run the same sweep in every one of them. The trigger belongs to whichever app owns the job.
|
|
|
29 |
*/
|
|
|
30 |
@Service
|
|
|
31 |
public class RecordingArchiveService {
|
|
|
32 |
|
|
|
33 |
private static final Logger LOGGER = LogManager.getLogger(RecordingArchiveService.class);
|
|
|
34 |
|
|
|
35 |
@Autowired
|
|
|
36 |
private LeadCallRepository leadCallRepository;
|
|
|
37 |
|
|
|
38 |
@Autowired
|
|
|
39 |
private ObjectStoreService objectStoreService;
|
|
|
40 |
|
|
|
41 |
@Autowired
|
|
|
42 |
private LmsDialerProvider dialerProvider;
|
|
|
43 |
|
|
|
44 |
@Autowired
|
|
|
45 |
private RecordingArchiveWorker recordingArchiveWorker;
|
|
|
46 |
|
|
|
47 |
@Value("${lms.recording.archive.enabled:true}")
|
|
|
48 |
private boolean enabled;
|
|
|
49 |
|
|
|
50 |
@Value("${lms.recording.archive.batch.size:25}")
|
|
|
51 |
private int batchSize;
|
|
|
52 |
|
|
|
53 |
/**
|
|
|
54 |
* How far back to look for recordings. MUST track the provider's own retention — Vonage keeps a
|
|
|
55 |
* recording for 30 days, and a shorter window here silently makes recoverable audio
|
|
|
56 |
* unrecoverable after any pause in the sweep.
|
|
|
57 |
*/
|
|
|
58 |
@Value("${lms.recording.provider.retention.days:30}")
|
|
|
59 |
private int providerRetentionDays;
|
|
|
60 |
|
|
|
61 |
/** Give up after this many failed fetches so a dead row cannot occupy a batch slot forever. */
|
|
|
62 |
@Value("${lms.recording.archive.max.attempts:5}")
|
|
|
63 |
private int maxAttempts;
|
|
|
64 |
|
|
|
65 |
/**
|
|
|
66 |
* Log recordings the sweep will never pick up again.
|
|
|
67 |
*
|
|
|
68 |
* <p>The pending query excludes rows past the provider's retention window or over the attempt
|
|
|
69 |
* ceiling — necessary, or one dead row starves every batch behind it. But an exclusion that
|
|
|
70 |
* happens silently is how you discover during an audit that a month of audio is missing, so the
|
|
|
71 |
* dropped rows are named here.
|
|
|
72 |
*/
|
|
|
73 |
private void reportAbandoned() {
|
|
|
74 |
try {
|
|
|
75 |
List<LeadCall> abandoned =
|
|
|
76 |
leadCallRepository.selectAbandonedRecordings(50, providerRetentionDays, maxAttempts);
|
|
|
77 |
if (abandoned == null || abandoned.isEmpty()) {
|
|
|
78 |
return;
|
|
|
79 |
}
|
|
|
80 |
StringBuilder ids = new StringBuilder();
|
|
|
81 |
for (LeadCall call : abandoned) {
|
|
|
82 |
if (ids.length() > 0) {
|
|
|
83 |
ids.append(", ");
|
|
|
84 |
}
|
|
|
85 |
ids.append(call.getId());
|
|
|
86 |
}
|
|
|
87 |
LOGGER.error("{} call recording(s) will NOT be archived — past the {}-day provider window "
|
|
|
88 |
+ "or over {} failed attempts. Call ids: {}",
|
|
|
89 |
abandoned.size(), providerRetentionDays, maxAttempts, ids);
|
|
|
90 |
} catch (Exception e) {
|
|
|
91 |
LOGGER.warn("Could not report abandoned recordings", e);
|
|
|
92 |
}
|
|
|
93 |
}
|
|
|
94 |
|
|
|
95 |
/**
|
|
|
96 |
* Ask the vendor for the recording of each recently answered call and stamp the handle on the row.
|
|
|
97 |
*
|
|
|
98 |
* <p>Runs before {@link #sweep()} and is what makes recordings exist at all for a server-placed
|
|
|
99 |
* provider: those place the call over REST and receive no recording webhook, so without this step
|
|
|
100 |
* {@code recording_uuid} stays null forever and the archival sweep — which selects on that column
|
|
|
101 |
* — never sees a single call.
|
|
|
102 |
*
|
|
|
103 |
* <p>Deliberately not done at hang-up: the vendor needs a moment to finalise the file, and a call
|
|
|
104 |
* that has just ended usually is not in the recording list yet. Being a sweep makes the retry free.
|
|
|
105 |
*
|
|
|
106 |
* @return how many recordings were newly linked
|
|
|
107 |
*/
|
|
|
108 |
@Transactional
|
|
|
109 |
public int linkSweep() {
|
|
|
110 |
if (!enabled || !dialerProvider.isConfigured()) {
|
|
|
111 |
return 0;
|
|
|
112 |
}
|
|
|
113 |
List<LeadCall> pending = leadCallRepository.selectPendingRecordingLink(batchSize, providerRetentionDays);
|
|
|
114 |
if (pending == null || pending.isEmpty()) {
|
|
|
115 |
return 0;
|
|
|
116 |
}
|
|
|
117 |
int linked = 0;
|
|
|
118 |
for (LeadCall call : pending) {
|
|
|
119 |
try {
|
|
|
120 |
LmsDialerProvider.RecordingRef ref = dialerProvider.findRecording(call);
|
|
|
121 |
if (ref == null || ref.id == null) {
|
|
|
122 |
// Normal: not recorded, or not finalised yet. The next sweep asks again.
|
|
|
123 |
continue;
|
|
|
124 |
}
|
|
|
125 |
call.setRecordingUuid(ref.id);
|
|
|
126 |
if (ref.durationSeconds > 0) {
|
|
|
127 |
call.setRecordingDuration(ref.durationSeconds);
|
|
|
128 |
}
|
|
|
129 |
leadCallRepository.persist(call);
|
|
|
130 |
linked++;
|
|
|
131 |
LOGGER.info("Linked recording {} to call {} (vendor call {})",
|
|
|
132 |
ref.id, call.getId(), call.getProviderCallUuid());
|
|
|
133 |
} catch (Exception e) {
|
|
|
134 |
// One bad row must not stop the batch — the rest still get linked.
|
|
|
135 |
LOGGER.warn("Could not link a recording for call {}", call.getId(), e);
|
|
|
136 |
}
|
|
|
137 |
}
|
|
|
138 |
return linked;
|
|
|
139 |
}
|
|
|
140 |
|
|
|
141 |
/**
|
|
|
142 |
* Queue every pending recording for archival.
|
|
|
143 |
*
|
|
|
144 |
* @return how many were queued; 0 when disabled, unconfigured, or nothing is waiting
|
|
|
145 |
*/
|
|
|
146 |
@Transactional
|
|
|
147 |
public int sweep() {
|
|
|
148 |
if (!enabled) {
|
|
|
149 |
return 0;
|
|
|
150 |
}
|
|
|
151 |
if (!objectStoreService.isEnabled() || !dialerProvider.isConfigured()) {
|
|
|
152 |
LOGGER.debug("Recording archive skipped — object store or dialer not configured");
|
|
|
153 |
return 0;
|
|
|
154 |
}
|
|
|
155 |
List<LeadCall> pending = leadCallRepository.selectPendingRecordingArchive(batchSize, providerRetentionDays, maxAttempts);
|
|
|
156 |
if (pending == null || pending.isEmpty()) {
|
|
|
157 |
return 0;
|
|
|
158 |
}
|
|
|
159 |
LOGGER.info("Queueing {} call recording(s) for archival", pending.size());
|
|
|
160 |
reportAbandoned();
|
|
|
161 |
for (LeadCall call : pending) {
|
|
|
162 |
// Through the injected worker, never this.archiveOne — a self-call would bypass the
|
|
|
163 |
// proxy and lose both @Async and @Transactional.
|
|
|
164 |
recordingArchiveWorker.archiveOne(call.getId());
|
|
|
165 |
}
|
|
|
166 |
return pending.size();
|
|
|
167 |
}
|
|
|
168 |
}
|