Merge branch 'develop' into content-xml

This commit is contained in:
m.nazemi
2026-07-11 21:47:44 +03:30
10 changed files with 868 additions and 82 deletions
+73
View File
@@ -70,6 +70,79 @@ type SearchForm struct {
ExcludeRejectedTenders bool `query:"-" valid:"optional"`
}
// HasListFilters reports whether the form applies any MongoDB list filters.
func (f *SearchForm) HasListFilters() bool {
if f == nil {
return false
}
if f.DocumentsScraped || f.OnlyActiveDeadlines || f.ExcludeRejectedTenders {
return true
}
if f.Search != nil && strings.TrimSpace(*f.Search) != "" {
return true
}
if f.Title != nil && strings.TrimSpace(*f.Title) != "" {
return true
}
if f.Description != nil && strings.TrimSpace(*f.Description) != "" {
return true
}
if f.NoticeType != nil && strings.TrimSpace(*f.NoticeType) != "" {
return true
}
if len(f.NoticeTypes) > 0 || len(f.FormTypes) > 0 {
return true
}
if f.ProcurementType != nil && strings.TrimSpace(*f.ProcurementType) != "" {
return true
}
if f.Country != nil && strings.TrimSpace(*f.Country) != "" {
return true
}
if f.CountryCode != nil && strings.TrimSpace(*f.CountryCode) != "" {
return true
}
if len(f.CountryCodes) > 0 || len(f.RegionCodes) > 0 {
return true
}
if f.MainClassification != nil && strings.TrimSpace(*f.MainClassification) != "" {
return true
}
if len(f.Classifications) > 0 || len(f.CpvCodes) > 0 {
return true
}
if f.MinEstimatedValue != nil || f.MaxEstimatedValue != nil || f.Currency != "" {
return true
}
if f.CreatedAt != nil || f.CreatedAtFrom != nil || f.CreatedAtTo != nil {
return true
}
if f.TenderDeadline != nil || f.TenderDeadlineFrom != nil || f.TenderDeadlineTo != nil ||
f.DeadlineFrom != nil || f.DeadlineTo != nil {
return true
}
if f.PublicationDate != nil || f.PublicationDateFrom != nil || f.PublicationDateTo != nil {
return true
}
if f.SubmissionDeadline != nil || f.SubmissionDateAliasFrom != nil || f.SubmissionDateAliasTo != nil ||
f.SubmissionDateFrom != nil || f.SubmissionDateTo != nil {
return true
}
if len(f.Status) > 0 || len(f.Source) > 0 || len(f.Languages) > 0 {
return true
}
if f.BuyerOrganizationID != nil && strings.TrimSpace(*f.BuyerOrganizationID) != "" {
return true
}
if f.NoticePublicationID != nil && strings.TrimSpace(*f.NoticePublicationID) != "" {
return true
}
if len(f.ContractFolderIDsWithDocuments) > 0 {
return true
}
return false
}
// SearchResponse represents the response for listing tenders
type SearchResponse struct {
Tenders []TenderResponse `json:"tenders"`
+9
View File
@@ -129,6 +129,15 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Te
*orm.NewIndex("main_classification_idx", bson.D{{Key: "main_classification", Value: 1}}),
*orm.NewIndex("publication_date_idx", bson.D{{Key: "publication_date", Value: -1}}),
*orm.NewIndex("tender_deadline_idx", bson.D{{Key: "tender_deadline", Value: 1}}),
*orm.NewIndex("submission_deadline_idx", bson.D{{Key: "submission_deadline", Value: 1}}),
*orm.NewIndex("closing_soon_submission_idx", bson.D{
{Key: "submission_deadline", Value: 1},
{Key: "status", Value: 1},
}),
*orm.NewIndex("closing_soon_tender_deadline_idx", bson.D{
{Key: "tender_deadline", Value: 1},
{Key: "status", Value: 1},
}),
*orm.NewIndex("estimated_value_idx", bson.D{{Key: "estimated_value", Value: -1}}),
*orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
// Stable keyset pagination for default admin list (created_at desc).
+240
View File
@@ -0,0 +1,240 @@
package tender
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
goredis "github.com/redis/go-redis/v9"
"tm/pkg/response"
)
const (
searchListCacheTTL = 60 * time.Second
// searchListCacheStaleTTL bounds how long the default unfiltered list may lag after
// writes. Only isCacheableSearchList queries use this cache (~5 min max staleness).
searchListCacheStaleTTL = 5 * time.Minute
searchListCacheKeyPrefix = "tender:search:list:"
)
type searchListCacheEntry struct {
expiresAt time.Time
staleUntil time.Time
value *SearchResponse
}
type cachedSearchListPayload struct {
Tenders []TenderResponse `json:"tenders"`
Metadata *response.Meta `json:"metadata,omitempty"`
}
func searchResponseToCachePayload(resp *SearchResponse) *cachedSearchListPayload {
if resp == nil {
return nil
}
return &cachedSearchListPayload{
Tenders: resp.Tenders,
Metadata: resp.Metadata,
}
}
func cachePayloadToSearchResponse(payload *cachedSearchListPayload) *SearchResponse {
if payload == nil {
return nil
}
return &SearchResponse{
Tenders: payload.Tenders,
Metadata: payload.Metadata,
}
}
func isCacheableSearchList(form *SearchForm, pagination *response.Pagination) bool {
if form == nil || pagination == nil {
return false
}
if form.HasListFilters() {
return false
}
if form.CompanyID != nil && strings.TrimSpace(*form.CompanyID) != "" {
return false
}
if form.CustomerID != nil && strings.TrimSpace(*form.CustomerID) != "" {
return false
}
if len(form.CompanyIDs) > 0 {
return false
}
if pagination.Cursor != "" || pagination.IncludeTotal || pagination.Offset != 0 {
return false
}
if pagination.Limit <= 0 || pagination.Limit > 20 {
return false
}
sortBy := pagination.SortBy
if sortBy == "" {
sortBy = "created_at"
}
sortOrder := pagination.SortOrder
if sortOrder == "" {
sortOrder = "desc"
}
return sortBy == "created_at" && sortOrder == "desc"
}
func searchListCacheKey(pagination *response.Pagination, language string) string {
return fmt.Sprintf("%d:%s", pagination.Limit, strings.ToLower(strings.TrimSpace(language)))
}
func (s *tenderService) cachedSearchList(ctx context.Context, form *SearchForm, pagination *response.Pagination, language string, load func(context.Context) (*SearchResponse, error)) (*SearchResponse, error) {
if !isCacheableSearchList(form, pagination) {
return load(ctx)
}
// Cached *SearchResponse values (including the Tenders slice) are shared across
// concurrent callers via singleflight and stale hits; treat as read-only.
cacheKey := searchListCacheKey(pagination, language)
redisKey := searchListCacheKeyPrefix + cacheKey
if cached, ok := s.getFreshSearchListCache(cacheKey); ok {
return cached, nil
}
if stale, ok := s.getStaleSearchListCache(cacheKey); ok {
go s.refreshSearchListCache(cacheKey, redisKey, load)
return stale, nil
}
if cached, ok := s.getRedisSearchListCache(ctx, redisKey); ok {
s.storeSearchListCache(cacheKey, cached)
go s.refreshSearchListCache(cacheKey, redisKey, load)
return cached, nil
}
out, err, _ := s.searchListCacheGroup.Do(cacheKey, func() (interface{}, error) {
if cached, ok := s.getFreshSearchListCache(cacheKey); ok {
return cached, nil
}
result, err := load(context.WithoutCancel(ctx))
if err != nil {
return nil, err
}
s.storeSearchListCache(cacheKey, result)
s.storeRedisSearchListCache(context.Background(), redisKey, result)
return result, nil
})
if err != nil {
return nil, err
}
return out.(*SearchResponse), nil
}
func (s *tenderService) refreshSearchListCache(cacheKey, redisKey string, load func(context.Context) (*SearchResponse, error)) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
result, err := load(ctx)
if err != nil {
s.logger.Warn("Failed to refresh tender search list cache", map[string]interface{}{
"cache_key": cacheKey,
"error": err.Error(),
})
return
}
s.storeSearchListCache(cacheKey, result)
s.storeRedisSearchListCache(context.Background(), redisKey, result)
}
func (s *tenderService) getFreshSearchListCache(cacheKey string) (*SearchResponse, bool) {
s.searchListCacheMu.Lock()
defer s.searchListCacheMu.Unlock()
entry, ok := s.searchListCache[cacheKey]
if !ok || time.Now().After(entry.expiresAt) {
return nil, false
}
return entry.value, true
}
func (s *tenderService) getStaleSearchListCache(cacheKey string) (*SearchResponse, bool) {
s.searchListCacheMu.Lock()
defer s.searchListCacheMu.Unlock()
entry, ok := s.searchListCache[cacheKey]
if !ok || time.Now().After(entry.staleUntil) {
if ok {
delete(s.searchListCache, cacheKey)
}
return nil, false
}
return entry.value, true
}
func (s *tenderService) storeSearchListCache(cacheKey string, value *SearchResponse) {
now := time.Now()
s.searchListCacheMu.Lock()
defer s.searchListCacheMu.Unlock()
s.searchListCache[cacheKey] = searchListCacheEntry{
expiresAt: now.Add(searchListCacheTTL),
staleUntil: now.Add(searchListCacheTTL + searchListCacheStaleTTL),
value: value,
}
}
func (s *tenderService) getRedisSearchListCache(ctx context.Context, redisKey string) (*SearchResponse, bool) {
if s.redisClient == nil {
return nil, false
}
raw, err := s.redisClient.Get(ctx, redisKey)
if err != nil {
if err != goredis.Nil {
s.logger.Warn("Failed to read tender search list cache from Redis", map[string]interface{}{
"cache_key": redisKey,
"error": err.Error(),
})
}
return nil, false
}
var cached cachedSearchListPayload
if err := json.Unmarshal([]byte(raw), &cached); err != nil {
s.logger.Warn("Failed to decode tender search list cache from Redis", map[string]interface{}{
"cache_key": redisKey,
"error": err.Error(),
})
_ = s.redisClient.Del(ctx, redisKey)
return nil, false
}
return cachePayloadToSearchResponse(&cached), true
}
func (s *tenderService) storeRedisSearchListCache(ctx context.Context, redisKey string, value *SearchResponse) {
if s.redisClient == nil || value == nil {
return
}
encoded, err := json.Marshal(searchResponseToCachePayload(value))
if err != nil {
s.logger.Warn("Failed to encode tender search list cache for Redis", map[string]interface{}{
"cache_key": redisKey,
"error": err.Error(),
})
return
}
if err := s.redisClient.Set(ctx, redisKey, string(encoded), searchListCacheTTL+searchListCacheStaleTTL); err != nil {
s.logger.Warn("Failed to store tender search list cache in Redis", map[string]interface{}{
"cache_key": redisKey,
"error": err.Error(),
})
}
}
+15 -4
View File
@@ -10,6 +10,7 @@ import (
"path"
"path/filepath"
"strings"
"sync"
"time"
"tm/internal/company"
"tm/internal/customer"
@@ -20,6 +21,7 @@ import (
"tm/pkg/response"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/singleflight"
)
// AISummarizerClient defines the interface for on-demand AI operations.
@@ -95,6 +97,9 @@ type tenderService struct {
redisClient redis.Client
pageCacheTTL time.Duration
defaultLanguage string
searchListCacheMu sync.Mutex
searchListCache map[string]searchListCacheEntry
searchListCacheGroup singleflight.Group
}
const (
@@ -136,6 +141,7 @@ func NewService(
redisClient: redisClient,
pageCacheTTL: recommendationPageCacheTTL,
defaultLanguage: strings.ToLower(defaultLanguage),
searchListCache: make(map[string]searchListCacheEntry),
}
}
@@ -922,8 +928,13 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
"to": form.DeadlineTo,
})
// Profile keywords are not used for tender search.
language := s.pickResponseLanguage(form.Language)
return s.cachedSearchList(ctx, form, pagination, language, func(loadCtx context.Context) (*SearchResponse, error) {
return s.searchUncached(loadCtx, form, pagination, language)
})
}
func (s *tenderService) searchUncached(ctx context.Context, form *SearchForm, pagination *response.Pagination, language string) (*SearchResponse, error) {
page, err := s.repository.Search(ctx, form, pagination)
if err != nil {
s.logger.Error("Failed to list tenders", map[string]interface{}{
@@ -936,7 +947,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
if form.CompanyID == nil || *form.CompanyID == "" {
tenderResponses := make([]TenderResponse, 0, len(page.Items))
for _, tender := range page.Items {
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(language))
}
return &SearchResponse{
@@ -955,7 +966,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
// Continue without match percentage if company not found
tenderResponses := make([]TenderResponse, 0, len(page.Items))
for _, tender := range page.Items {
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(language))
}
return &SearchResponse{
@@ -978,7 +989,7 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination
tenderResponses := make([]TenderResponse, 0, len(page.Items))
for _, tender := range page.Items {
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(s.pickResponseLanguage(form.Language)))
tenderResponses = append(tenderResponses, *tender.ToResponseWithLanguage(language))
}
return &SearchResponse{