crm/services/trial_expiry.go
2026-01-13 18:02:43 +08:00

310 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"time"
)
// TrialExpiryChecker checks for trial expiry and sends notifications
type TrialExpiryChecker struct {
feishuWebhook string
}
// NewTrialExpiryChecker creates a new trial expiry checker
func NewTrialExpiryChecker(feishuWebhook string) *TrialExpiryChecker {
return &TrialExpiryChecker{
feishuWebhook: feishuWebhook,
}
}
// Customer represents a customer record
type Customer struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
CustomerName string `json:"customerName"`
IntendedProduct string `json:"intendedProduct"`
Version string `json:"version"`
Description string `json:"description"`
Solution string `json:"solution"`
Type string `json:"type"`
Module string `json:"module"`
StatusProgress string `json:"statusProgress"`
Reporter string `json:"reporter"` // Now used for trial period
}
// TrialPeriod represents a trial period record
type TrialPeriod struct {
ID string `json:"id"`
CustomerID string `json:"customerId"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
CreatedAt time.Time `json:"createdAt"`
}
// ParseTrialPeriod parses the trial period string
// Supports formats like: "2026/1/52026/1/16", "2026/1/5~2026/1/16", "2026-1-52026-1-16"
func ParseTrialPeriod(trialStr string) (startDate, endDate time.Time, err error) {
if trialStr == "" {
return time.Time{}, time.Time{}, fmt.Errorf("empty trial period")
}
// Replace different separators with standard ~
trialStr = strings.ReplaceAll(trialStr, "", "~")
trialStr = strings.ReplaceAll(trialStr, " ", "")
// Split by ~
parts := strings.Split(trialStr, "~")
if len(parts) != 2 {
return time.Time{}, time.Time{}, fmt.Errorf("invalid trial period format")
}
// Parse dates - support multiple formats
layouts := []string{
"2006/1/2",
"2006-1-2",
"2006/01/02",
"2006-01-02",
}
var parseErr error
for _, layout := range layouts {
startDate, parseErr = time.Parse(layout, strings.TrimSpace(parts[0]))
if parseErr == nil {
break
}
}
if parseErr != nil {
return time.Time{}, time.Time{}, fmt.Errorf("failed to parse start date: %v", parseErr)
}
for _, layout := range layouts {
endDate, parseErr = time.Parse(layout, strings.TrimSpace(parts[1]))
if parseErr == nil {
break
}
}
if parseErr != nil {
return time.Time{}, time.Time{}, fmt.Errorf("failed to parse end date: %v", parseErr)
}
return startDate, endDate, nil
}
// CheckAndNotify checks for customers whose trial expires soon and sends notifications
func (t *TrialExpiryChecker) CheckAndNotify(customers []Customer) error {
if t.feishuWebhook == "" {
log.Println("Feishu webhook not configured, skipping trial expiry notifications")
return nil
}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, customer := range customers {
if customer.Reporter == "" {
continue
}
_, endDate, err := ParseTrialPeriod(customer.Reporter)
if err != nil {
// Skip invalid trial periods
continue
}
// Check if trial expires within 3 days
endDateOnly := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, endDate.Location())
daysUntilExpiry := int(endDateOnly.Sub(today).Hours() / 24)
// Send notification for trials expiring today, tomorrow, or within 3 days
if daysUntilExpiry >= 0 && daysUntilExpiry <= 3 {
err := t.sendExpiryNotification(customer.CustomerName, customer.Reporter, daysUntilExpiry)
if err != nil {
log.Printf("Failed to send expiry notification for %s: %v", customer.CustomerName, err)
} else {
log.Printf("Sent trial expiry notification for customer: %s (expires in %d days)", customer.CustomerName, daysUntilExpiry)
}
}
}
return nil
}
// CheckTrialPeriodsAndNotify checks trial periods and sends notifications
func (t *TrialExpiryChecker) CheckTrialPeriodsAndNotify(trialPeriods []TrialPeriod, customersMap map[string]string) error {
if t.feishuWebhook == "" {
log.Println("Feishu webhook not configured, skipping trial expiry notifications")
return nil
}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, period := range trialPeriods {
endDateOnly := time.Date(period.EndTime.Year(), period.EndTime.Month(), period.EndTime.Day(), 0, 0, 0, 0, period.EndTime.Location())
daysUntilExpiry := int(endDateOnly.Sub(today).Hours() / 24)
// Send notification for trials expiring today, tomorrow, or within 3 days
if daysUntilExpiry >= 0 && daysUntilExpiry <= 3 {
customerName := customersMap[period.CustomerID]
if customerName == "" {
customerName = period.CustomerID
}
trialPeriodStr := fmt.Sprintf("%s%s",
period.StartTime.Format("2006/1/2 15:04"),
period.EndTime.Format("2006/1/2 15:04"))
err := t.sendExpiryNotification(customerName, trialPeriodStr, daysUntilExpiry)
if err != nil {
log.Printf("Failed to send expiry notification for %s: %v", customerName, err)
} else {
log.Printf("Sent trial expiry notification for customer: %s (expires in %d days)", customerName, daysUntilExpiry)
}
}
}
return nil
}
// sendExpiryNotification sends a rich Feishu card notification for trial expiry
func (t *TrialExpiryChecker) sendExpiryNotification(customerName, trialPeriod string, daysUntilExpiry int) error {
var title, urgencyLevel, urgencyColor, expiryText string
// Determine urgency level and message based on days until expiry
switch daysUntilExpiry {
case 0:
title = "🔴 试用今日到期提醒"
urgencyLevel = "紧急"
urgencyColor = "red"
expiryText = "今天"
case 1:
title = "🟠 试用明日到期提醒"
urgencyLevel = "重要"
urgencyColor = "orange"
expiryText = "明天"
case 2:
title = "🟡 试用即将到期提醒"
urgencyLevel = "提醒"
urgencyColor = "blue"
expiryText = "2天后"
case 3:
title = "🔵 试用即将到期提醒"
urgencyLevel = "提醒"
urgencyColor = "blue"
expiryText = "3天后"
default:
title = "📅 试用到期提醒"
urgencyLevel = "提醒"
urgencyColor = "blue"
expiryText = fmt.Sprintf("%d天后", daysUntilExpiry)
}
// Create rich card message
message := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"header": map[string]interface{}{
"title": map[string]interface{}{
"tag": "plain_text",
"content": title,
},
"template": urgencyColor,
},
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"fields": []interface{}{
map[string]interface{}{
"is_short": true,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**客户名称**\n%s", customerName),
},
},
map[string]interface{}{
"is_short": true,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**紧急程度**\n%s", urgencyLevel),
},
},
},
},
map[string]interface{}{
"tag": "div",
"fields": []interface{}{
map[string]interface{}{
"is_short": false,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**试用时间**\n%s", trialPeriod),
},
},
},
},
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("⏰ 该客户的试用期将于 **%s** 到期,请及时跟进!", expiryText),
},
},
map[string]interface{}{
"tag": "hr",
},
map[string]interface{}{
"tag": "note",
"elements": []interface{}{
map[string]interface{}{
"tag": "plain_text",
"content": fmt.Sprintf("发送时间:%s", time.Now().Format("2006-01-02 15:04:05")),
},
},
},
},
},
}
jsonData, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal message: %v", err)
}
// Debug: Print the JSON being sent
log.Printf("DEBUG: Sending Feishu message: %s", string(jsonData))
resp, err := http.Post(t.feishuWebhook, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("feishu API returned status: %d", resp.StatusCode)
}
return nil
}
// ExtractEndDate extracts the end date from trial period string for display
func ExtractEndDate(trialStr string) string {
if trialStr == "" {
return ""
}
// Use regex to extract the end date
re := regexp.MustCompile(`[~]\s*(.+)$`)
matches := re.FindStringSubmatch(trialStr)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return trialStr
}