add routers and logic for online donat

This commit is contained in:
harold 2025-05-15 10:16:02 +05:00
parent a4cbb43ccd
commit ae5301a222
11 changed files with 286 additions and 27 deletions

View File

@ -713,6 +713,40 @@ func GetDonatForPlaying(donatService model.DonatService) echo.HandlerFunc {
}
}
// UpdateStreamerOnline godoc
// @Summary Update streamer online status
// @Description Marks streamer as online in the system
// @Tags Donate
// @Accept json
// @Produce json
// @Param streamer-id path string true "Streamer ID (numeric)"
// @Success 200 {object} map[string]interface{} "Success status"
// @Failure 400 {object} echo.HTTPError "Invalid streamer ID"
// @Failure 500 {object} echo.HTTPError "Internal server error"
// @Router /update-streamer-online/{streamer-id} [put]
func UpdateStreamerOnline(donatService model.DonatService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := context.Background()
streamerIDParam := c.Param("streamer-id")
streamerID, err := strconv.Atoi(streamerIDParam)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid streamer ID format")
}
err = donatService.UpdateStreamerOnline(ctx, streamerID)
if err != nil {
slog.Error("Failed to update streamer online status", "error", err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update online status")
}
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"message": "streamer online status updated",
})
}
}
// UpdateLoginDonatePage godoc
// @Summary Update streamer login
// @Description Updates the streamer login associated with the donate page

View File

@ -107,6 +107,7 @@ func IncludeDonatHandlers(
server.GET(PREFIX+"/get-donat-for-playing/:streamer-id", GetDonatForPlaying(donatService))
server.GET(PREFIX+"/text-after-donat/:order-id", GetMessageAfterDonat(donatService))
server.POST(PREFIX+"/update-login-donate", UpdateLoginDonatePage(donatService))
server.PUT(PREFIX+"/update-streamer-online/:streamer-id", UpdateStreamerOnline(donatService))
}
func IncludeWidgetHandlers(

View File

@ -1201,6 +1201,51 @@ const docTemplate = `{
}
}
},
"/update-streamer-online/{streamer-id}": {
"put": {
"description": "Marks streamer as online in the system",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Donate"
],
"summary": "Update streamer online status",
"parameters": [
{
"type": "string",
"description": "Streamer ID (numeric)",
"name": "streamer-id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Success status",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Invalid streamer ID",
"schema": {
"$ref": "#/definitions/echo.HTTPError"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/echo.HTTPError"
}
}
}
}
},
"/voice-settings": {
"get": {
"security": [

View File

@ -1194,6 +1194,51 @@
}
}
},
"/update-streamer-online/{streamer-id}": {
"put": {
"description": "Marks streamer as online in the system",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Donate"
],
"summary": "Update streamer online status",
"parameters": [
{
"type": "string",
"description": "Streamer ID (numeric)",
"name": "streamer-id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Success status",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Invalid streamer ID",
"schema": {
"$ref": "#/definitions/echo.HTTPError"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/echo.HTTPError"
}
}
}
}
},
"/voice-settings": {
"get": {
"security": [

View File

@ -1328,6 +1328,36 @@ paths:
summary: Update streamer login
tags:
- Donate
/update-streamer-online/{streamer-id}:
put:
consumes:
- application/json
description: Marks streamer as online in the system
parameters:
- description: Streamer ID (numeric)
in: path
name: streamer-id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Success status
schema:
additionalProperties: true
type: object
"400":
description: Invalid streamer ID
schema:
$ref: '#/definitions/echo.HTTPError'
"500":
description: Internal server error
schema:
$ref: '#/definitions/echo.HTTPError'
summary: Update streamer online status
tags:
- Donate
/voice-settings:
get:
consumes:

View File

@ -4,6 +4,7 @@ import (
"donat-widget/internal/model/api"
"github.com/google/uuid"
"mime/multipart"
"time"
)
import (
@ -108,6 +109,7 @@ type DonatService interface {
UpdateStreamerLogin(ctx context.Context, streamerLogin string, streamerID int) error
UpdateAvatarStreamer(token, avatarId string) error
GetTextAfterDonatByOrder(ctx context.Context, orderID uuid.UUID) (string, error)
UpdateStreamerOnline(ctx context.Context, streamerID int) error
}
type DonatRepo interface {
@ -178,6 +180,8 @@ type DonatRepo interface {
GetPlayingDonat(ctx context.Context, streamerID int) (PlayingDonat, error)
UpdateStreamerLogin(ctx context.Context, streamerLogin string, streamerID int) error
CreateStreamerOnline(ctx context.Context, streamerID int) error
GetLastStreamerOnline(ctx context.Context, streamerID int) (time.Time, error)
}
type TargetService interface {

View File

@ -131,6 +131,12 @@ CREATE TABLE IF NOT EXISTS streamers_widgets_pages (
streamer_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS streamers_online (
id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
streamer_id INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
);
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$

View File

@ -589,3 +589,9 @@ SELECT streamer_id FROM streamers_widgets_pages WHERE id = @widget_page_id`
var UpdateStreamerLogin = `
UPDATE donate_pages SET streamer_login = @streamer_login WHERE streamer_id = @streamer_id
RETURNING streamer_login;`
var InsertStreamerOnline = `
INSERT INTO streamers_online (streamer_id) VALUES (@streamer_id)`
var GetLastStreamerOnline = `
SELECT created_at FROM streamers_online WHERE streamer_id = @streamer_id`

View File

@ -64,6 +64,20 @@ func GetTemplate1(streamerID int, donatHost, ttsHost string) string {
let widgetUrl = 'https://%s/api';
let ttsUrl = 'https://%s/api/tts';
// Функция для периодических запросов онлайн-статуса
function startHeartbeat(streamerID) {
setInterval(async () => {
try {
await fetch(widgetUrl + '/update-streamer-online/' + streamerID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'}
});
} catch (error) {
console.error('Heartbeat error:', error);
}
}, 10000); // 10 секунд
}
function createTextWithAmount(text, amount) {
const container = document.createElement('div');
container.className = 'text-container';
@ -120,7 +134,7 @@ function playAudio(url, volume, signal) {
if (resolved) return;
resolved = true;
audio.pause();
audio.src = ""; // Release resources
audio.src = "";
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('error', onError);
reject(new DOMException('Aborted', 'AbortError'));
@ -148,16 +162,15 @@ function playAudio(url, volume, signal) {
audio.addEventListener('error', onError, { once: true });
audio.play().catch(err => {
// play() can reject if interrupted or no user gesture
if (resolved) return;
resolved = true;
signal?.removeEventListener('abort', onAbort);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('error', onError);
if (err.name === 'AbortError' && signal?.aborted) {
reject(new DOMException('Aborted', 'AbortError')); // Propagate abort
reject(new DOMException('Aborted', 'AbortError'));
} else {
reject(err); // Other play() error
reject(err);
}
});
});
@ -208,7 +221,7 @@ function playSpeech(text, voiceSettings, signal) {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(requestBody),
signal: signal // Pass signal to fetch
signal: signal
})
.then(response => {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
@ -241,10 +254,9 @@ function playSpeech(text, voiceSettings, signal) {
return audio.play();
})
.catch(error => {
// This catches errors from fetch, blob processing, or audio.play()
if (resolved) return; // Already handled
signal?.removeEventListener('abort', onAbort); // Ensure listener removed
cleanupAndReject(error); // Pass the original error
if (resolved) return;
signal?.removeEventListener('abort', onAbort);
cleanupAndReject(error);
});
});
}
@ -273,26 +285,24 @@ async function playMedia(donat, voiceSettings) {
ttsAudio = await playSpeech(donat.text, voiceSettings, controller.signal);
}
} catch (error) {
if (error.name !== 'AbortError') { // Only re-throw non-abort errors
if (error.name !== 'AbortError') {
console.error('Error during media playback:', error);
throw error;
}
// If AbortError, it's handled by the timeout logic or external abort.
}
})();
const timeoutPromise = new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
controller.abort(); // Signal all operations to stop
resolve('timeout'); // Resolve to indicate timeout completion
}, (donat.duration || 0) * 1000); // Use donat.duration, default to 0 if undefined
controller.abort();
resolve('timeout');
}, (donat.duration || 0) * 1000);
});
try {
await Promise.race([mediaOperation, timeoutPromise]);
} finally {
clearTimeout(timeoutId); // Crucial: clear timeout if mediaOperation finished first or errored
// Ensure audio elements are stopped and reset if they were initiated and not properly cleaned up by abort
clearTimeout(timeoutId);
if (audioElement && !audioElement.paused) {
audioElement.pause();
audioElement.currentTime = 0;
@ -312,6 +322,8 @@ function clearContainer(container) {
async function widgetView() {
const streamerID = '%v';
startHeartbeat(streamerID); // Запускаем heartbeat
const contentDiv = document.getElementById('content');
if (!contentDiv) {
@ -321,15 +333,15 @@ async function widgetView() {
while (true) {
const iterationStart = Date.now();
let currentDonat = null; // Store current donat for duration calculation
let currentDonat = null;
try {
const donatData = await getDonatInfo(streamerID);
if (!donatData || Object.keys(donatData).length === 0) {
await new Promise(r => setTimeout(r, 5000)); // Wait before retrying
await new Promise(r => setTimeout(r, 5000));
continue;
}
currentDonat = donatData; // Assign to currentDonat
currentDonat = donatData;
clearContainer(contentDiv);
@ -363,7 +375,6 @@ async function widgetView() {
voice_enabled: currentDonat.voice_enabled
};
// playMedia will ensure audio plays for at most currentDonat.duration
await playMedia(currentDonat, voiceSettings);
if (currentDonat.order_id) {
@ -379,18 +390,14 @@ async function widgetView() {
}
} catch (error) {
// Log errors from getDonatInfo, playMedia, or other processing
console.error('Ошибка обработки доната в цикле:', error);
// If error, maybe wait a bit before next iteration to avoid spamming
if (!currentDonat) { // e.g. error in getDonatInfo
if (!currentDonat) {
await new Promise(r => setTimeout(r, 5000));
continue;
}
}
const elapsedMs = Date.now() - iterationStart;
// Total display time for the donation (visuals + audio)
// Use currentDonat.duration if available, otherwise default (e.g., 5 seconds)
const targetDisplayTimeMs = (currentDonat?.duration || 5) * 1000;
const remainingTimeMs = Math.max(0, targetDisplayTimeMs - elapsedMs);
@ -398,7 +405,6 @@ async function widgetView() {
if (remainingTimeMs > 0) {
await new Promise(r => setTimeout(r, remainingTimeMs));
}
// If elapsedMs already exceeds targetDisplayTimeMs, loop will continue immediately
}
}

View File

@ -881,3 +881,36 @@ func (repoDonat *RepoDonat) UpdateStreamerLogin(
}
return nil
}
func (repoDonat *RepoDonat) CreateStreamerOnline(
ctx context.Context,
streamerID int,
) error {
args := pgx.NamedArgs{
"streamer_id": streamerID,
}
err := repoDonat.db.Exec(ctx, sql.InsertStreamerOnline, args)
if err != nil {
return err
}
return nil
}
func (repoDonat *RepoDonat) GetLastStreamerOnline(
ctx context.Context,
streamerID int,
) (time.Time, error) {
args := pgx.NamedArgs{
"streamer_id": streamerID,
}
row := repoDonat.db.SelectOne(ctx, sql.GetLastStreamerOnline, args)
var StreamerOnline time.Time
err := row.Scan(&StreamerOnline)
if err != nil {
return StreamerOnline, err
}
return StreamerOnline, nil
}

View File

@ -14,6 +14,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
)
type ServiceDonat struct {
@ -343,10 +344,18 @@ func (donatService *ServiceDonat) GetOuterDonatPage(
}
}
err = donatService.getLastStreamerOnline(ctx, donatePage.StreamerID)
var online string
if err != nil {
online = "offline"
} else {
online = "online"
}
var outerDonatePageResponse = model.OuterDonatePageResponse{
Description: donatePage.Description,
Login: donatePage.StreamerLogin,
OnLine: "online",
OnLine: online,
BackgroundImg: donatService.storage.DownloadLink(donatePage.BackgroundImgFileId),
HeadImg: donatService.storage.DownloadLink(donatePage.HeadImgFileId),
AvatarImg: donatService.storage.DownloadLink(avatarFileId),
@ -856,6 +865,46 @@ func (donatService *ServiceDonat) replaceFilteredWords(text string, words []stri
})
}
func (donatService *ServiceDonat) UpdateStreamerOnline(
ctx context.Context,
streamerID int,
) error {
err := donatService.donatRepo.CreateStreamerOnline(
ctx,
streamerID,
)
if err != nil {
return err
}
return nil
}
func (donatService *ServiceDonat) getLastStreamerOnline(
ctx context.Context,
streamerID int,
) error {
onlineTime, err := donatService.donatRepo.GetLastStreamerOnline(
ctx,
streamerID,
)
if err != nil {
return err
}
currentTime := time.Now().UTC()
diff := currentTime.Sub(onlineTime)
if diff < 0 {
diff = -diff
}
if diff > time.Minute {
return fmt.Errorf("last online time is more than a minute old")
}
return nil
}
func (donatService *ServiceDonat) replaceLinks(text string) string {
re := regexp.MustCompile(`(?i)\bhttps?://\S+\b`)
return re.ReplaceAllStringFunc(text, func(match string) string {