æžžæé®ä»¶ç³»ç»ïŒäžåªæ¯å䞪éç¥
è¿æ¯"æžžæè¿è¥å·¥å ·"ç³»åç第äžç¯ãåŸå€äººè§åŸåé®ä»¶æ¯ä»¶ç®åçäºââäžå°±æ¯äžªæ¶æ¯æšéåïŒäœçæ£åè¿æžžæè¿è¥ç人éœç¥éïŒé®ä»¶ç³»ç»æ¯è¿è¥çæ žå¿åºç¡è®Ÿæœä¹äžãä»å€©æä»¬æ¥èèè¿äžªç䌌ç®åãå®åå€æçææ¯ç³»ç»ã
åŒèš
"ç»ææ 30 级以äžçç©å®¶åäžå°æŽ»åšéç¥ïŒé垊 100 éåžå¥å±ã"
å¬èµ·æ¥åŸç®åïŒå¯¹å§ïŒ
äœåŠææåè¯äœ ïŒè¿äžªéæ±èåæ¶åïŒ
- åŠäœåš 5 åéå 宿 200 äžå°é®ä»¶çåéïŒ
- åŠäœç¡®ä¿ç©å®¶ç»åœåç¬¬äžæ¶éŽçå°é®ä»¶ïŒ
- åŠäœé²æ¢é®ä»¶è¢«æ¶æå·åïŒ
- åŠäœè®©è¿è¥åäºèœç¬ç«å®æè¿ç±»æäœïŒ
é®ä»¶ç³»ç»ïŒä»æ¥å°±äžæ¯"å䞪éç¥"é£ä¹ç®åã宿¯æžžæè¿è¥çé圢åºç¡è®ŸæœïŒæ¯æç仿¥åžžéç¥å°å€§å掻åšçåç§åºæ¯ãäžå¥å¥œçé®ä»¶ç³»ç»ïŒèœè®©è¿è¥äºåååïŒäžå¥æé®é¢çé®ä»¶ç³»ç»ïŒåå¯èœæäžºäºæ çæºå€Žã
äžãæžžæé®ä»¶ç³»ç»çç¹æ®æ§
1.1 äžæ®éé®ä»¶çæ¬èŽšåºå«
æžžæé®ä»¶ç³»ç»åæä»¬æ¥åžžäœ¿çšç Email ææ¬èŽšäžåïŒ
| 绎床 | æ®éé®ä»¶ | æžžæé®ä»¶ |
|---|---|---|
| èœœäœ | ç¬ç«çé®ç®±åºçš | æžžæå åµç³»ç» |
| 觊蟟 | åŒæ¥ïŒçšæ·äž»åšæ¥ç | ç»åœå³æšé |
| éä»¶ | æä»¶äžºäž» | èæç©åãèŽ§åž |
| æ¶æ | å¯é¿æä¿å | åžžææææéå¶ |
| å®å | æé®ç®±å°å | æç©å®¶å±æ§çé |
| åéŠ | å·²è¯»åæ§ïŒå¯éïŒ | 宿Žçè¡äžºè¿œèžª |
æžžæé®ä»¶æ¯æžžæè¿è¥çæ žå¿è§ŠèŸŸæž éïŒå®äžåªæ¯äŒ éä¿¡æ¯ïŒæŽæ¯åæŸå¥å±ãç»Žç³»å ³ç³»ãé©±åšæŽ»è·çéèŠå·¥å ·ã
1.2 æžžæé®ä»¶çç¬ç¹ä»·åŒ
䞺ä»ä¹æžžæååèŠè±å€§åæ°å»ºè®Ÿé®ä»¶ç³»ç»ïŒèäžæ¯çŽæ¥çšç¬¬äžæ¹æšéïŒ
1.3 å žå䜿çšåºæ¯
é®ä»¶ç³»ç»åšæžžæè¿è¥äžçåºçšåºæ¯é垞广æ³ïŒ
- 绎æ€å ¬åãçæ¬æŽæ°æé
- 掻åšé¢åãéæ¶çŠå©éç¥
- ç³»ç»è§ååæŽè¯Žæ
- å åŒè¿å©ãéŠå å¥å±
- æŽ»åšæåå¥å±
- è¡¥å¿ç€Œå ïŒç»Žæ€è¡¥å¿ãbug è¡¥å¿ïŒ
- æµå€±ç©å®¶çå®åå¬å
- é«ä»·åŒç©å®¶ç VIP äžå±å ³æ
- æ°æåŒå¯Œçé¶æ®µæ§å¥å±
- æ°åèœ/æ°è±éä»ç»
- éæ¶äŒæ æé
- èåšæŽ»åšå®£äŒ
å¯ä»¥è¯ŽïŒé®ä»¶ç³»ç»æ¯è¿æ¥æžžæäžç©å®¶çæ žå¿æ¡¥æ¢ä¹äžã
äºãé®ä»¶ç³»ç»çæ žå¿åèœ
2.1 é®ä»¶ç±»ååå
ä»äžå¡è§åºŠïŒæžžæé®ä»¶å¯ä»¥å䞺以äžå ç±»ïŒæ¯ç±»çææ¯å€çæ¹åŒææäžåïŒ
ææ¯ç¹ç¹ïŒäžéèŠé¢å 计ç®ç®æ ç©å®¶å衚ïŒéçš"å»¶è¿è®¡ç®"çç¥ââé®ä»¶å æ°æ®åªåäžä»œïŒç©å®¶ç»åœæ¶åšæå€ææ¯åŠåºè¯¥æ¶å°ã
ææ¯ç¹ç¹ïŒéèŠé¢å 计ç®ç¬Šåæ¡ä»¶çç©å®¶åè¡šïŒæè ååšç鿡件åšç©å®¶ç»åœæ¶åšæå¹é ã
ææ¯ç¹ç¹ïŒæç®åç圢åŒïŒçŽæ¥åå ¥ç©å®¶é®ç®±å³å¯ã
ææ¯ç¹ç¹ïŒéèŠä»»å¡è°åºŠç³»ç»æ¯æïŒå°æèªåšè§Šååéæµçšã
2.2 éä»¶ç³»ç»è®Ÿè®¡
é®ä»¶éä»¶æ¯æžžæé®ä»¶çæ žå¿ä»·åŒä¹äžãäžäžªå®åçéä»¶ç³»ç»éèŠå€çå€äžªç»ŽåºŠïŒ
- 莧åžç±»ïŒéåžãé»ç³ãç»å®é»ç³ç
- éå ·ç±»ïŒæ¶èåãè£ å€ãææ
- å€è§ç±»ïŒç®è€ãç§°å·ãå€Žåæ¡
- ç¹æ®ç±»ïŒç¢çã积åãæœå¥åž
äžåç±»åçç©åïŒåšæŸç€ºãé¢åãäœ¿çšæ¶æäžåçå€çé»èŸã
// é件项å®ä¹
type Attachment struct {
Type int `json:"type"` // ç©åç±»åïŒ1=èŽ§åž 2=éå
· 3=å€è§
ItemID int `json:"item_id"` // ç©åID
Count int `json:"count"` // æ°é
ExpireAt int64 `json:"expire_at"` // è¿ææ¶éŽïŒ0衚瀺氞äžè¿æïŒ
ExtraData string `json:"extra_data"` // æ©å±æ°æ®ïŒåŠè£
å€å±æ§ïŒ
}
// é®ä»¶å®ä¹
type Mail struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Sender string `json:"sender"`
Attachments []Attachment `json:"attachments"`
ExpireAt int64 `json:"expire_at"`
CreatedAt int64 `json:"created_at"`
}
- äžæ¬¡æ§é¢åïŒé¢ååé®ä»¶æ¶å€±
- å¯éå€é¢åïŒæ¯å€©/æ¯åšå¯é¢äžæ¬¡
- ææ¡ä»¶é¢åïŒèŸŸå°æç级ã宿æä»»å¡æèœé¢å
- éæ©æ§é¢åïŒå€äžªéä»¶äžéäžäžª
- éä»¶æ¬èº«çæææïŒéå ·è¿ææ¶éŽïŒ
- é®ä»¶çæææïŒå€ä¹ æªé¢åèªåšå é€ïŒ
- é¢ååçäœ¿çšæé
è¿äºæ¶éŽç»ŽåºŠéèŠæž æ°åºåïŒé¿å ç©å®¶å°æã
- åäžé®ä»¶äžèœéå€é¢å
- åŒåžžé¢åè¡äžºæ£æµïŒçæ¶éŽå€§éé¢åïŒ
- 莊å·åŒåžžæ£æµïŒæ°æ³šåèŽŠå·æ¹éé¢åïŒ
2.3 çéèœå
å®åé®ä»¶çæ žå¿åšäºçéãçéèœåè¶åŒºïŒè¿è¥èœåç粟å觊蟟就è¶å€ã
åžžè§ççé绎床ïŒ
// ç鿡件å®ä¹
type MailFilter struct {
LevelRange *IntRange `json:"level_range"` // ç级èåŽ
VIPRange *IntRange `json:"vip_range"` // VIPç级èåŽ
LastLoginDays *IntRange `json:"last_login_days"` // æåç»åœå€©æ°
TotalRecharge *IntRange `json:"total_recharge"` // 环计å
åŒéé¢
RegisterTime *TimeRange `json:"register_time"` // æ³šåæ¶éŽèåŽ
Channels []string `json:"channels"` // æž éå衚
Platforms []string `json:"platforms"` // å¹³å°å衚
CustomTags []string `json:"custom_tags"` // èªå®ä¹æ çŸ
}
type IntRange struct {
Min *int `json:"min"`
Max *int `json:"max"`
}
type TimeRange struct {
Start *int64 `json:"start"`
End *int64 `json:"end"`
}
倿çç鿡件å¯ä»¥ç»å䜿çšïŒæ¯åŠ"iOS å¹³å° + 环计å åŒè¶ è¿ 500 å + 7 倩æªç»åœ"ïŒå®ç°ç²Ÿåå¬åã
2.4 ç¶æç®¡ç
é®ä»¶ä»åå»ºå°æ¶äº¡ïŒç»åå€äžªç¶æïŒ
å·²å建 â åŸ
åé â åéäž â å·²é蟟 â å·²é
读 â å·²é¢å â å·²è¿æ/å·²å é€
âââââââââââââââââââââââââââââââââââââââââââ
â â
⌠â
ââââââââââââ ââââââââââââ ââââââââââââ ââââââââââââ â ââââââââââââ
â å·²å建 âââââ¶â åŸ
åé âââââ¶â åéäž âââââ¶â å·²é蟟 ââââŒââ¶â å·²è¿æ â
ââââââââââââ ââââââââââââ ââââââââââââ ââââââââââââ â ââââââââââââ
â â
⌠â
ââââââââââââ â
â å·²é
读 â â
ââââââââââââ â
â â
âââââââââââââââââŒââââââââââ
⌠âŒ
ââââââââââââ ââââââââââââ
â å·²é¢å â â å·²å é€ â
ââââââââââââ ââââââââââââ
æ¯äžªç¶æç蜬æ¢éœéèŠè®°åœæ¶éŽåæäœè ïŒè¿æå 䞪éèŠæä¹ïŒ
äžãé®ä»¶æš¡æ¿ç³»ç»è¯Šè§£
3.1 䞺ä»ä¹éèŠæš¡æ¿ïŒ
è¿è¥æ¯å€©å¯èœèŠåå åå°äžåç±»åçé®ä»¶ãåŠææ²¡ææš¡æ¿ç³»ç»ïŒ
- å 容飿 Œäžç»äžïŒåœ±ååç圢象
- æ ŒåŒå®¹æåºéïŒåéæ¿æ¢éæŒ
- æçäœäžïŒæ¯æ¬¡éœèŠä»å€Žå
- éŸä»¥è¿œèžªææïŒæ æ³å¯¹æ¯äŒå
æš¡æ¿ç³»ç»è®©è¿è¥èœå¿«éå建æ ååçé®ä»¶ïŒåæ¶ä¿ç䞪æ§å空éŽã
3.2 æš¡æ¿åŒæè®Ÿè®¡
äžäžªçµæŽ»çæš¡æ¿ç³»ç»éèŠæ¯æåéæ¿æ¢ãæ¡ä»¶æž²æã埪ç¯çèœåã
æ é¢ïŒã{event_type}ã{event_name} å¥å±å·²åæŸ
亲ç±ç {player_name}ïŒ
{if rank <= 10}
æåæšåšã{event_name}ãæŽ»åšäžè·åŸç¬¬ {rank} åïŒè¿æ¯æšåºåŸçè£èïŒ
{else}
æè°¢æšåäžã{event_name}ãæŽ»åšïŒæšè·åŸäºç¬¬ {rank} åç奜æç»©ïŒ
{endif}
å¥å±å
容ïŒ
{foreach reward in rewards}
- {reward.name} x {reward.count}
{endforeach}
{if vip_level >= 5}
äœäžºå°è޵ç VIP{vip_level} äŒåïŒæšè¿é¢å€è·åŸäº 10% å¥å±å æïŒ
{endif}
é¢åæªæ¢ïŒ{expire_date}
è¯·åæ¶é¢åïŒç¥æšæžžææå¿«ïŒ
{game_name} è¿è¥å¢é
// æš¡æ¿åŒæ
type TemplateEngine struct {
templates map[string]*Template
}
// æš¡æ¿å®ä¹
type Template struct {
ID int64 `json:"id"`
Code string `json:"code"` // æš¡æ¿çŒç
Name string `json:"name"` // æš¡æ¿åç§°
Category string `json:"category"` // åç±»ïŒsystem/activity/marketing
TitleTmpl string `json:"title_tmpl"` // æ é¢æš¡æ¿
ContentTmpl string `json:"content_tmpl"` // æ£ææš¡æ¿
DefaultTTL int `json:"default_ttl"` // é»è®€æææïŒå°æ¶ïŒ
Version int `json:"version"` // çæ¬å·
}
// æž²æäžäžæ
type RenderContext struct {
Player *PlayerInfo
Event *EventInfo
Rewards []RewardInfo
Variables map[string]interface{}
}
// æž²ææš¡æ¿
func (e *TemplateEngine) Render(tmplCode string, ctx *RenderContext) (title, content string, err error) {
tmpl, ok := e.templates[tmplCode]
if !ok {
return "", "", fmt.Errorf("template not found: %s", tmplCode)
}
title, err = e.renderString(tmpl.TitleTmpl, ctx)
if err != nil {
return "", "", err
}
content, err = e.renderString(tmpl.ContentTmpl, ctx)
if err != nil {
return "", "", err
}
return title, content, nil
}
// è§£æåé
func (e *TemplateEngine) renderString(tmpl string, ctx *RenderContext) (string, error) {
// åéæ¿æ¢ïŒ{variable} -> å®é
åŒ
result := variableRegex.ReplaceAllStringFunc(tmpl, func(match string) string {
varName := strings.Trim(match, "{}")
return e.getVariable(varName, ctx)
})
// æ¡ä»¶æž²æïŒ{if condition}...{endif}
result = e.processConditionals(result, ctx)
// åŸªç¯æž²æïŒ{foreach item in list}...{endforeach}
result = e.processLoops(result, ctx)
return result, nil
}
3.3 æš¡æ¿å类管ç
æäžå¡åºæ¯å类暡æ¿ïŒäŸ¿äºæ¥æŸå管çïŒ
// å建暡æ¿
type CreateTemplateRequest struct {
Code string `json:"code" binding:"required"`
Name string `json:"name" binding:"required"`
Category string `json:"category" binding:"required"`
TitleTmpl string `json:"title_tmpl" binding:"required"`
ContentTmpl string `json:"content_tmpl" binding:"required"`
DefaultTTL int `json:"default_ttl"`
}
// æš¡æ¿é¢è§
type PreviewTemplateRequest struct {
TemplateCode string `json:"template_code"`
SampleData map[string]interface{} `json:"sample_data"`
}
// è¿åé¢è§ç»æ
type PreviewTemplateResponse struct {
Title string `json:"title"`
Content string `json:"content"`
Variables []string `json:"variables"` // æš¡æ¿äžäœ¿çšçåéå衚
}
3.4 æš¡æ¿çæ¬ç®¡ç
æš¡æ¿äžçº¿åå¯èœéèŠä¿®æ¹ïŒäœå·²åéçé®ä»¶äžåºå圱åãå æ€éèŠçæ¬ç®¡çïŒ
// åéé®ä»¶æ¶è®°åœæš¡æ¿çæ¬
type Mail struct {
ID int64 `json:"id"`
TemplateCode string `json:"template_code"`
TemplateVersion int `json:"template_version"` // è®°åœäœ¿çšççæ¬
// ... å
¶ä»å段
}
// è·åæš¡æ¿æ¶æå®çæ¬
func (s *TemplateService) GetTemplate(code string, version int) (*Template, error) {
if version == 0 {
// è·åææ°çæ¬
return s.getLatestTemplate(code)
}
// è·åæå®çæ¬
return s.getTemplateByVersion(code, version)
}
3.5 æš¡æ¿ææè¿œèžª
åäžç±»åçé®ä»¶ïŒäœ¿çšäžåæš¡æ¿ïŒææå¯èœå·®åŒåŸå€§ã
- æåŒçïŒæ¶å°é®ä»¶çç©å®¶äžïŒæå€å°æåŒäº
- é¢åçïŒæåŒé®ä»¶çç©å®¶äžïŒæå€å°é¢åäºéä»¶
- ç¹å»çïŒåŠæé®ä»¶äžæéŸæ¥ïŒæå€å°äººç¹å»äº
// A/B æµè¯é
眮
type ABTestConfig struct {
ID int64 `json:"id"`
Name string `json:"name"`
TemplateA string `json:"template_a"` // æš¡æ¿AçŒç
TemplateB string `json:"template_b"` // æš¡æ¿BçŒç
Ratio float64 `json:"ratio"` // Aç»æ¯äŸïŒ0.0-1.0ïŒ
TargetCount int `json:"target_count"` // ç®æ æ ·æ¬é
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
}
// æ ¹æ®ç©å®¶IDåç»
func (s *ABTestService) GetTemplateForPlayer(testID int64, playerID int64) (string, error) {
config, err := s.getTestConfig(testID)
if err != nil {
return "", err
}
// 䜿çšç©å®¶IDçååžåŒè¿è¡åç»ïŒä¿è¯åäžç©å®¶å§ç»åšåäžç»
hash := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%d", playerID)))
if float64(hash%100)/100 < config.Ratio {
return config.TemplateA, nil
}
return config.TemplateB, nil
}
// ç»è®¡æµè¯ç»æ
type ABTestResult struct {
TemplateCode string `json:"template_code"`
SentCount int `json:"sent_count"`
OpenCount int `json:"open_count"`
ClaimCount int `json:"claim_count"`
OpenRate float64 `json:"open_rate"`
ClaimRate float64 `json:"claim_rate"`
}
åãæ¹éåéçææ¯ææäžè§£å³æ¹æ¡
4.1 è§æš¡çææ
åè®Ÿäœ çæžžææ 500 äžæŽ»è·ç©å®¶ïŒèŠåäžå°å šæé®ä»¶ïŒ
4.2 å»¶è¿åå ¥çç¥ïŒæ žå¿äŒåïŒ
äŒ ç»æ¹åŒïŒäžºæ¯äžªç©å®¶åå»ºäžæ¡é®ä»¶è®°åœã500 äžç©å®¶ = 500 äžæ¡è®°åœã
æ žå¿æè·¯ïŒ
- é®ä»¶å æ°æ®ïŒå 容ãéä»¶ãç鿡件ïŒååšäžæ¬¡
- ç©å®¶ç»åœæ¶ïŒæ ¹æ®ç鿡件倿æ¯åŠåºè¯¥æ¶å°è¿å°é®ä»¶
- é¢ååæå建å®é çé¢åè®°åœ
è¿ç§æ¹åŒè®©"å šæé®ä»¶"åæäº"äžä»œæ°æ® + è§å"ïŒæ 论ç©å®¶å€å°ïŒååšææ¬æå®ã
// é®ä»¶å®ä¹ïŒåªåäžä»œïŒ
type MailDefinition struct {
ID int64 `json:"id"`
Type int `json:"type"` // 1=å
šæ 2=å®å 3=䞪人
Title string `json:"title"`
Content string `json:"content"`
Attachments []Attachment `json:"attachments"`
Filter *MailFilter `json:"filter"` // ç鿡件
StartTime int64 `json:"start_time"` // çæåŒå§æ¶éŽ
EndTime int64 `json:"end_time"` // çæç»ææ¶éŽ
CreatedAt int64 `json:"created_at"`
}
// ç©å®¶é®ä»¶ç®±ïŒåªè®°åœå·²é¢åçïŒ
type PlayerMailbox struct {
ID int64 `json:"id"`
PlayerID int64 `json:"player_id"`
MailDefID int64 `json:"mail_def_id"` // å
³èé®ä»¶å®ä¹
Status int `json:"status"` // 1=已读 2=å·²é¢å
ReadAt int64 `json:"read_at"`
ClaimedAt int64 `json:"claimed_at"`
}
// è·åç©å®¶é®ä»¶å衚
func (s *MailService) GetPlayerMails(playerID int64, page, pageSize int) ([]*Mail, int64, error) {
// 1. è·åææçæäžçé®ä»¶å®ä¹
now := time.Now().Unix()
mailDefs, err := s.getActiveMailDefinitions(now)
if err != nil {
return nil, 0, err
}
// 2. è·åç©å®¶å·²å€ççé®ä»¶
processedMails, err := s.getPlayerProcessedMails(playerID)
if err != nil {
return nil, 0, err
}
processedMap := make(map[int64]bool)
for _, pm := range processedMails {
processedMap[pm.MailDefID] = true
}
// 3. çéç©å®¶åºè¯¥çå°çé®ä»¶
playerInfo, _ := s.getPlayerInfo(playerID)
var mails []*Mail
for _, def := range mailDefs {
// å·²å€çè¿ïŒå·²é¢å/å·²å é€ïŒ
if processedMap[def.ID] {
continue
}
// æ£æ¥ç鿡件
if !s.matchFilter(playerInfo, def.Filter) {
continue
}
// 蜬æ¢äžºè¿åæ ŒåŒ
mails = append(mails, &Mail{
ID: def.ID,
Title: def.Title,
Content: def.Content,
Attachments: def.Attachments,
ExpireAt: def.EndTime,
})
}
// 4. å页è¿å
total := int64(len(mails))
start := (page - 1) * pageSize
end := start + pageSize
if end > len(mails) {
end = len(mails)
}
return mails[start:end], total, nil
}
// å¹é
ç鿡件
func (s *MailService) matchFilter(player *PlayerInfo, filter *MailFilter) bool {
if filter == nil {
return true // æ ç鿡件ïŒå
šæé®ä»¶
}
// ç级èåŽ
if filter.LevelRange != nil {
if filter.LevelRange.Min != nil && player.Level < *filter.LevelRange.Min {
return false
}
if filter.LevelRange.Max != nil && player.Level > *filter.LevelRange.Max {
return false
}
}
// VIPç级
if filter.VIPRange != nil {
if filter.VIPRange.Min != nil && player.VIPLevel < *filter.VIPRange.Min {
return false
}
if filter.VIPRange.Max != nil && player.VIPLevel > *filter.VIPRange.Max {
return false
}
}
// æž é
if len(filter.Channels) > 0 {
found := false
for _, ch := range filter.Channels {
if player.Channel == ch {
found = true
break
}
}
if !found {
return false
}
}
return true
}
4.3 玢åŒäžçŒåçç¥
ç©å®¶æåŒé®ç®±æ¶ïŒéèŠå¿«éè¿åé®ä»¶å衚ãè¿æ¶åæ°æ®åºå±é¢çäŒåã
-- é®ä»¶å®ä¹è¡šçŽ¢åŒ
CREATE INDEX idx_mail_def_active ON mail_definitions(start_time, end_time);
CREATE INDEX idx_mail_def_type ON mail_definitions(type);
-- ç©å®¶é®ç®±è¡šçŽ¢åŒ
CREATE INDEX idx_player_mailbox_player ON player_mailbox(player_id);
CREATE INDEX idx_player_mailbox_player_def ON player_mailbox(player_id, mail_def_id);
// çŒåçéšé®ä»¶å®ä¹
func (s *MailService) getActiveMailDefinitions(now int64) ([]*MailDefinition, error) {
cacheKey := fmt.Sprintf("mail:defs:active:%d", now/300) // 5åéæŽæ°äžæ¬¡
// å
ä»çŒåè·å
if cached, err := s.cache.Get(cacheKey); err == nil {
return cached.([]*MailDefinition), nil
}
// 仿°æ®åºæ¥è¯¢
defs, err := s.repo.FindActiveMailDefinitions(now)
if err != nil {
return nil, err
}
// åå
¥çŒå
s.cache.Set(cacheKey, defs, 5*time.Minute)
return defs, nil
}
// çŒåç©å®¶æªè¯»æ°é
func (s *MailService) GetUnreadCount(playerID int64) (int, error) {
cacheKey := fmt.Sprintf("mail:unread:%d", playerID)
if cached, err := s.cache.Get(cacheKey); err == nil {
return cached.(int), nil
}
// è®¡ç®æªè¯»æ°é
mails, _, err := s.GetPlayerMails(playerID, 1, 1000)
if err != nil {
return 0, err
}
count := len(mails)
s.cache.Set(cacheKey, count, 1*time.Minute)
return count, nil
}
// é¢ååæž
é€çŒå
func (s *MailService) ClaimMail(playerID, mailID int64) error {
// ... é¢åé»èŸ
// æž
逿ªè¯»æ°çŒå
s.cache.Delete(fmt.Sprintf("mail:unread:%d", playerID))
return nil
}
- ç©å®¶ç»åœæ¶é¢åæªè¯»é®ä»¶æ°é
- UI 屿åå±ç€ºçº¢ç¹/æ°åè§æ
4.4 ä»»å¡éå讟计
å€§è§æš¡åéäžèœåæ¥æ§è¡ïŒéèŠä»»å¡éåè§£èŠïŒ
[è¿è¥åå°] â åå»ºä»»å¡ â [ä»»å¡éå] â [åéæå¡] â [ååšå±]
// é®ä»¶åéä»»å¡
type MailSendTask struct {
TaskID int64 `json:"task_id"`
MailDefID int64 `json:"mail_def_id"`
Type string `json:"type"` // broadcast/targeted/personal
TargetCount int `json:"target_count"`
Progress int `json:"progress"` // å·²å€çæ°é
Status string `json:"status"` // pending/running/completed/failed
CreatedAt int64 `json:"created_at"`
StartedAt int64 `json:"started_at"`
CompletedAt int64 `json:"completed_at"`
Error string `json:"error"`
}
// ä»»å¡å€çåš
type MailTaskProcessor struct {
queue *TaskQueue
mailService *MailService
workerNum int
}
func (p *MailTaskProcessor) Start() {
for i := 0; i < p.workerNum; i++ {
go p.worker()
}
}
func (p *MailTaskProcessor) worker() {
for {
task, err := p.queue.Pop()
if err != nil {
time.Sleep(1 * time.Second)
continue
}
p.processTask(task)
}
}
func (p *MailTaskProcessor) processTask(task *MailSendTask) {
// æŽæ°ç¶æäžºè¿è¡äž
task.Status = "running"
task.StartedAt = time.Now().Unix()
p.queue.UpdateTask(task)
switch task.Type {
case "targeted":
// å®åé®ä»¶ïŒåæ¹å€çç©å®¶å衚
p.processTargetedMail(task)
case "personal":
// 䞪人é®ä»¶ïŒçŽæ¥åå
¥
p.processPersonalMail(task)
}
}
func (p *MailTaskProcessor) processTargetedMail(task *MailSendTask) {
// è·åç鿡件
mailDef, _ := p.mailService.GetMailDefinition(task.MailDefID)
// åæ¹è·åç®æ ç©å®¶
batchSize := 1000
offset := 0
for {
players, err := p.mailService.GetPlayersByFilter(mailDef.Filter, offset, batchSize)
if err != nil {
task.Status = "failed"
task.Error = err.Error()
p.queue.UpdateTask(task)
return
}
if len(players) == 0 {
break
}
// æ¹éåå
¥ç©å®¶é®ç®±
for _, player := range players {
p.mailService.CreatePlayerMail(player.ID, mailDef)
task.Progress++
}
// æŽæ°è¿åºŠ
p.queue.UpdateTask(task)
offset += batchSize
// æ§å¶åå
¥éçïŒé¿å
å宿°æ®åº
time.Sleep(100 * time.Millisecond)
}
task.Status = "completed"
task.CompletedAt = time.Now().Unix()
p.queue.UpdateTask(task)
}
- åå³°å¡«è°·ïŒçªåç倧éåé请æ±äžäŒåå®ç³»ç»
- 倱莥éè¯ïŒå䞪任å¡å€±èŽ¥äžåœ±åæŽäœïŒå¯ä»¥èªåšéè¯
- è¿åºŠè¿œèžªïŒè¿è¥å¯ä»¥çå°åéè¿åºŠåç¶æ
4.5 å¹çæ§ä¿è¯
çœç»æåšãæå¡éå¯å¯èœå¯ŒèŽéå€åéãæ¯å°é®ä»¶éèŠæå¯äžæ è¯ïŒç¡®ä¿åäžé®ä»¶äžäŒéå€åºç°åšç©å®¶é®ç®±äžã
// å建ç©å®¶é®ä»¶ïŒå¹çïŒ
func (s *MailService) CreatePlayerMail(playerID, mailDefID int64) error {
// 䜿çšå¯äžçºŠæïŒplayer_id + mail_def_id
mailbox := &PlayerMailbox{
PlayerID: playerID,
MailDefID: mailDefID,
Status: 0,
}
// INSERT IGNORE æ ON DUPLICATE KEY UPDATE
err := s.db.Exec(`
INSERT IGNORE INTO player_mailbox (player_id, mail_def_id, status, created_at)
VALUES (?, ?, 0, ?)
`, playerID, mailDefID, time.Now().Unix())
return err
}
// é¢åéä»¶ïŒå¹çïŒ
func (s *MailService) ClaimAttachments(playerID, mailID int64) ([]Attachment, error) {
// 䜿çšååžåŒé鲿¢å¹¶åé¢å
lockKey := fmt.Sprintf("mail:claim:%d:%d", playerID, mailID)
lock := s.locker.Acquire(lockKey, 10*time.Second)
if lock == nil {
return nil, errors.New("please retry")
}
defer lock.Release()
// æ£æ¥æ¯åŠå·²é¢å
mailbox, err := s.getPlayerMail(playerID, mailID)
if err != nil {
return nil, err
}
if mailbox.Status == StatusClaimed {
// å·²é¢åïŒè¿å空ïŒå¹çïŒ
return nil, nil
}
// è·åé®ä»¶å®ä¹
mailDef, err := s.GetMailDefinition(mailID)
if err != nil {
return nil, err
}
// åæŸéä»¶
for _, att := range mailDef.Attachments {
if err := s.inventory.AddItem(playerID, att.ItemID, att.Count); err != nil {
return nil, err
}
}
// æŽæ°ç¶æ
mailbox.Status = StatusClaimed
mailbox.ClaimedAt = time.Now().Unix()
s.updatePlayerMail(mailbox)
return mailDef.Attachments, nil
}
äºãé®ä»¶é蟟å¯é æ§ä¿é
5.1 ä»ä¹æ¯"é蟟"ïŒ
åšæžžæé®ä»¶ç³»ç»äžïŒ"é蟟"æå äžªå±æ¬¡ïŒæ¯äžªå±æ¬¡éœææµå€±ïŒ
é蟟çäŒåïŒå°±æ¯èŠè®©æŽå€ç©å®¶èµ°å®è¿äžªæŒæïŒåå°æ¯äžªç¯èçæµå€±ã
5.2 å¯é æ§æ¶æè®Ÿè®¡
// åéé®ä»¶æ¶å
åå
¥æ¶æ¯éå
func (s *MailService) SendMail(req *SendMailRequest) (int64, error) {
// 1. å建é®ä»¶å®ä¹
mailDef := &MailDefinition{
Type: req.Type,
Title: req.Title,
Content: req.Content,
Attachments: req.Attachments,
Filter: req.Filter,
StartTime: time.Now().Unix(),
EndTime: time.Now().Add(7 * 24 * time.Hour).Unix(),
}
if err := s.repo.CreateMailDefinition(mailDef); err != nil {
return 0, err
}
// 2. åå
¥æ¶æ¯éåïŒæä¹
åïŒä¿è¯äžäž¢å€±ïŒ
msg := &MailSendMessage{
MailDefID: mailDef.ID,
Type: req.Type,
Filter: req.Filter,
}
if err := s.mq.Publish("mail.send", msg); err != nil {
// æ¶æ¯éååå
¥å€±èŽ¥ïŒåæ»
s.repo.DeleteMailDefinition(mailDef.ID)
return 0, err
}
return mailDef.ID, nil
}
// æ¶æ¯æ¶èŽ¹å€±èŽ¥æ¶çéè¯
type MailConsumer struct {
maxRetry int
retryDelay time.Duration
}
func (c *MailConsumer) HandleMessage(msg *MailSendMessage) error {
var lastErr error
for i := 0; i < c.maxRetry; i++ {
err := c.processMessage(msg)
if err == nil {
return nil
}
lastErr = err
log.Printf("mail send retry %d/%d: %v", i+1, c.maxRetry, err)
// ææ°éé¿
time.Sleep(c.retryDelay * time.Duration(1<<i))
}
// éè¯æ¬¡æ°çšå°œïŒåå
¥æ»ä¿¡éå
c.sendToDeadLetterQueue(msg, lastErr)
return lastErr
}
// çæ§ææ
type MailMetrics struct {
// åéçžå
³
SendTotal prometheus.Counter
SendSuccess prometheus.Counter
SendFailed prometheus.Counter
SendLatency prometheus.Histogram
// é¢åçžå
³
ClaimTotal prometheus.Counter
ClaimSuccess prometheus.Counter
ClaimFailed prometheus.Counter
// éåçžå
³
QueueSize prometheus.Gauge
QueueLag prometheus.Gauge
}
// å
³é®åèŠè§å
var alertRules = []AlertRule{
{
Name: "mail_send_failure_rate",
Condition: "rate(mail_send_failed[5m]) / rate(mail_send_total[5m]) > 0.01",
Severity: "critical",
Message: "é®ä»¶åé倱莥çè¶
è¿1%",
},
{
Name: "mail_queue_lag",
Condition: "mail_queue_lag > 10000",
Severity: "warning",
Message: "é®ä»¶éå积åè¶
è¿10000",
},
{
Name: "mail_claim_failure",
Condition: "rate(mail_claim_failed[5m]) > 10",
Severity: "warning",
Message: "é®ä»¶é¢å倱莥é¢çåŒåžž",
},
}
5.3 æ°æ®äžèŽæ§ä¿é
// é¢åéä»¶çååžåŒäºå¡
func (s *MailService) ClaimWithTransaction(playerID, mailID int64) error {
// äœ¿çš Saga æš¡åŒ
tx := s.txManager.Begin()
// Step 1: éå®é®ä»¶
mailbox, err := s.lockMail(tx, playerID, mailID)
if err != nil {
tx.Rollback()
return err
}
// Step 2: åæŸç©å
mailDef, _ := s.GetMailDefinition(mailID)
for _, att := range mailDef.Attachments {
if err := s.inventory.AddItemWithTx(tx, playerID, att.ItemID, att.Count); err != nil {
tx.Rollback()
return err
}
}
// Step 3: æŽæ°é®ä»¶ç¶æ
if err := s.updateMailStatus(tx, mailbox, StatusClaimed); err != nil {
tx.Rollback()
return err
}
// Step 4: è®°åœé¢åæ¥å¿
if err := s.logClaim(tx, playerID, mailID, mailDef.Attachments); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
äžäžäºå¡å€±èŽ¥åæ æ³èªåšæ¢å€ïŒéèŠäººå·¥è¡¥å¿æºå¶ïŒ
// é¢åè®°åœè¡šïŒçšäºå¯¹èŽŠåè¡¥å¿ïŒ
type ClaimRecord struct {
ID int64 `json:"id"`
PlayerID int64 `json:"player_id"`
MailID int64 `json:"mail_id"`
Attachments []Attachment `json:"attachments"`
Status int `json:"status"` // 0=åŸ
å€ç 1=æå 2=倱莥
RetryCount int `json:"retry_count"`
CreatedAt int64 `json:"created_at"`
}
// 宿¶æ£æ¥åŒåžžé¢åè®°åœ
func (s *MailService) CheckFailedClaims() {
records, _ := s.repo.GetFailedClaimRecords()
for _, record := range records {
if record.RetryCount >= 3 {
// 倿¬¡å€±èŽ¥ïŒåéåèŠ
s.alertService.Send("mail_claim_failed", record)
continue
}
// éè¯åæŸ
if err := s.retryClaim(record); err != nil {
record.RetryCount++
s.repo.UpdateClaimRecord(record)
} else {
record.Status = 1
s.repo.UpdateClaimRecord(record)
}
}
}
5.4 æåå±ç€ºçäžé 读ç
ç©å®¶æ¶å°é®ä»¶ïŒäœå¯èœæ ¹æ¬æ²¡æ³šæå°ãåŠäœæåå±ç€ºçïŒ
- 奜ïŒãå¥å±ãæšææªé¢åçåšå¹Žåºç€Œå
- å·®ïŒå ³äºæžžæåšå¹ŽåºæŽ»åšå¥å±åæŸçéç¥
5.5 æ°æ®åæäžè¿ä»£
æç»è¿œèžªé®ä»¶æŒææ°æ®ïŒåç°é®é¢å¹¶äŒåïŒ
// é®ä»¶æŒæç»è®¡
type MailFunnelStats struct {
MailDefID int64 `json:"mail_def_id"`
SentCount int `json:"sent_count"` // åéé
ViewCount int `json:"view_count"` // å±ç€ºé
OpenCount int `json:"open_count"` // æåŒé
ClaimCount int `json:"claim_count"` // é¢åé
ViewRate float64 `json:"view_rate"` // å±ç€ºç
OpenRate float64 `json:"open_rate"` // æåŒç
ClaimRate float64 `json:"claim_rate"` // é¢åç
}
// è®¡ç®æŒææ°æ®
func (s *MailService) GetMailFunnelStats(mailDefID int64) (*MailFunnelStats, error) {
stats := &MailFunnelStats{MailDefID: mailDefID}
// åééïŒå¯¹äºå®åé®ä»¶ïŒ
stats.SentCount = s.repo.GetSentCount(mailDefID)
// é
读é
stats.OpenCount = s.repo.GetOpenCount(mailDefID)
// é¢åé
stats.ClaimCount = s.repo.GetClaimCount(mailDefID)
// 计ç®èœ¬åç
if stats.SentCount > 0 {
stats.OpenRate = float64(stats.OpenCount) / float64(stats.SentCount)
stats.ClaimRate = float64(stats.ClaimCount) / float64(stats.SentCount)
}
return stats, nil
}
å ãå®å šäžé²å·æºå¶
6.1 åžžè§é£é©
é®ä»¶ç³»ç»æ¶åèæç©ååæŸïŒæ¯é»äº§çéç¹å ³æ³šå¯¹è±¡ã
6.2 鲿€çç¥
// é¢åè¡äžºé£æ§æ£æµ
type ClaimRiskChecker struct {
// éåŒé
眮
MaxClaimsPerMinute int // æ¯åéæå€§é¢å次æ°
MaxClaimsPerDay int // æ¯å€©æå€§é¢å次æ°
NewAccountGracePeriod int // æ°èŽŠå·è§å¯æïŒå€©ïŒ
}
func (c *ClaimRiskChecker) CheckClaimRisk(playerID int64) error {
// 1. æ£æ¥æ¯åéé¢åé¢ç
minuteCount := c.getClaimCount(playerID, time.Now().Add(-time.Minute))
if minuteCount >= c.MaxClaimsPerMinute {
return errors.New("claim too frequent")
}
// 2. æ£æ¥æ¯æ¥é¢åé¢ç
dayCount := c.getClaimCount(playerID, time.Now().Add(-24*time.Hour))
if dayCount >= c.MaxClaimsPerDay {
return errors.New("daily limit exceeded")
}
// 3. æ£æ¥èŽŠå·ç¶æ
player := c.getPlayer(playerID)
if time.Since(time.Unix(player.CreatedAt, 0)) < time.Duration(c.NewAccountGracePeriod)*24*time.Hour {
// æ°èŽŠå·ïŒæŽäž¥æ Œçéå¶
if dayCount >= 3 {
return errors.New("new account limit")
}
}
return nil
}
äžãè¿è¥å·¥å ·è®Ÿè®¡
7.1 ç®æŽçå建çé¢
让è¿è¥åäºèœç¬ç«å®æé®ä»¶åéïŒäžéèŠææ¯ä»å ¥ã
7.2 é¢è§äžæµè¯
åéåå åé¢è§åæµè¯ïŒé¿å "ååºå»å°±æ¶äžåæ¥"çå°Žå°¬ã
- åšææºç«¯æš¡æå±ç€ºææ
- æ£æ¥åéæ¿æ¢æ¯åŠæ£ç¡®
- éªè¯éä»¶é 眮æ¯åŠå®æŽ
7.3 åé管ç
åéäžæ¯"åå®å°±äžç®¡"ïŒè¿éèŠæç»ç®¡çã
- åæ¶åŸ åéçé®ä»¶
- æ€åå·²åéäœæªé¢åçé®ä»¶
- å»¶é¿æçŒ©çæææ
7.4 ææåæ
åé宿åïŒæäŸæ°æ®çæ¿ïŒè®©è¿è¥äºè§£ææã
- é蟟æ°éãé 读æ°éãé¢åæ°é
- åç¯è蜬åç
- äžåå²é®ä»¶çææå¯¹æ¯
å «ãç³»ç»æ¶ææŠè§
8.1 æŽäœæ¶æ
äžäžªå®æŽçæžžæé®ä»¶ç³»ç»ïŒéåžžå å«ä»¥äžæ žå¿æš¡åïŒ
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â è¿è¥ç®¡çåå° â
â ââââââââââââ ââââââââââââ ââââââââââââ ââââââââââââ â
â â é®ä»¶å建 â â æš¡æ¿ç®¡ç â â åé管ç â â ææåæ â â
â ââââââââââââ ââââââââââââ ââââââââââââ ââââââââââââ â
âââââââââââââââââââââââââââââ¬ââââââââââââââââââââââââââââââââââââââ
â
âŒ
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â API æå¡å± â
â ââââââââââââââââ ââââââââââââââââ ââââââââââââââââ â
â â é®ä»¶æ¥è¯¢ API â â é®ä»¶åé API â â éä»¶é¢å API â â
â ââââââââââââââââ ââââââââââââââââ ââââââââââââââââ â
âââââââââââââââââââââââââââââ¬ââââââââââââââââââââââââââââââââââââââ
â
âŒ
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â æ žå¿äžå¡å± â
â ââââââââââââââââ ââââââââââââââââ ââââââââââââââââ â
â â çéå¹é
æå¡ â â æš¡æ¿æž²ææå¡ â â éä»¶åæŸæå¡ â â
â ââââââââââââââââ ââââââââââââââââ ââââââââââââââââ â
âââââââââââââââââââââââââââââ¬ââââââââââââââââââââââââââââââââââââââ
â
âââââââââââââââââŒââââââââââââââââ
⌠⌠âŒ
ââââââââââââââââ ââââââââââââââââ ââââââââââââââââ
â ååšå± â â ä»»å¡è°åºŠå± â â çŒåå± â
â ââââââââââ â â ââââââââââ â â ââââââââââ â
â â MySQL â â â â Redis â â â â Redis â â
â ââââââââââ â â â Queue â â â â Cache â â
ââââââââââââââââ ââââââââââââââââ ââââââââââââââââ
8.2 æ žå¿æ°æ®æš¡å
8.3 äžå ¶ä»ç³»ç»ç亀äº
é®ä»¶ç³»ç»äžæ¯å€ç«çïŒéèŠäžå€äžªç³»ç»åäœïŒ
ä¹ãæ»ç»
æžžæé®ä»¶ç³»ç»ïŒè¡šé¢çæ¯"å䞪éç¥"ïŒå®é äžæ¯äžäžªæ¶åååšã计ç®ãæšéãå®å šãæ°æ®åæçå€æç³»ç»ã
- å»¶è¿åå ¥ïŒ å šæé®ä»¶äžçäºäžºæ¯äžªç©å®¶åäžä»œïŒèªæå°å©çšè§åååšæè®¡ç®ïŒå€§å¹ éäœååšææ¬
- æš¡æ¿åïŒ æ ååæåæçïŒäžªæ§åä¿ççµæŽ»åºŠïŒåæ¶æ¯æææè¿œèžªåæç»äŒå
- æ¹éå€çïŒ ä»»å¡éåãåçå¹¶è¡ãåŒæ¥åïŒåºå¯¹å€§è§æš¡åéçææ
- é蟟äŒåïŒ ä»ææ¯é蟟走åè¡äžºèœ¬åïŒå ³æ³šå®æŽçæŒæïŒèäžåªæ¯"ååºå»"
- å®å šé²æ€ïŒ æå¡ç«¯æ ¡éªãå¯äžæ§çºŠæãåŒåžžçæ§ïŒä¿æ€èæèµäº§å®å š
- 让è¿è¥èœå¿«éå建ååéé®ä»¶ïŒäžéèŠææ¯ä»å ¥
- 让ç©å®¶ç¬¬äžæ¶éŽçå°æä»·åŒçé®ä»¶ïŒå¹¶é¡ºç å°å®æé¢å
- è®©ææ¯å¢éäžæ å¿æ§èœåå®å šïŒç³»ç»çš³å®å¯é
- 让管çå±èœçå°æž æ°çæææ°æ®ïŒæ¯ææ°æ®é©±åšå³ç
é®ä»¶ç³»ç»æ¯æžžæè¿è¥çåºç¡è®Ÿæœãå®äžåææç³»ç»é£æ ·ç«é ·ïŒäžå瀟亀系ç»é£æ ·çé¹ïŒäœå®é»é»æ¯æçæ¥åžžè¿è¥çæ¹æ¹é¢é¢ãæå ¥èµæºæå®å奜ïŒåæ¥æ¯é¿æäžå¯è§çã
åšè®Ÿè®¡åè¯äŒ°é®ä»¶ç³»ç»æ¶ïŒå¯ä»¥åèä»¥äžæž åïŒ
- [ ] æ¯æå šæé®ä»¶ãå®åé®ä»¶ã䞪人é®ä»¶ã宿¶é®ä»¶
- [ ] æ¯æå€ç§ç±»åçéä»¶ïŒèާåžãéå ·ãå€è§çïŒ
- [ ] æ¯æçµæŽ»çç鿡件ç»å
- [ ] æ¯æé®ä»¶æš¡æ¿ç®¡çåçæ¬æ§å¶
- [ ] æ¯æ A/B æµè¯
- [ ] å šæé®ä»¶éçšå»¶è¿åå ¥çç¥
- [ ] æ¯æä»»å¡éååŒæ¥å€ç
- [ ] æ°æ®åºçŽ¢åŒåçïŒæ¥è¯¢æçé«
- [ ] æ¯æçŒåçéšé®ä»¶å 容
- [ ] æ¶æ¯éåæä¹ åïŒä¿è¯äžäž¢å€±
- [ ] 倱莥èªåšéè¯æºå¶
- [ ] æ»ä¿¡éåå€çåŒåžžæ¶æ¯
- [ ] ååžåŒäºå¡ä¿è¯æ°æ®äžèŽæ§
- [ ] è¡¥å¿æºå¶å€çåŒåžžæ åµ
- [ ] æå¡ç«¯æ ¡éªææé¢åæäœ
- [ ] æ°æ®åºå±é¢å¯äžæ§çºŠæ
- [ ] 宿Žçæäœæ¥å¿è®°åœ
- [ ] åŒåžžé¢åè¡äžºçæ§
- [ ] 飿§ç³»ç»èåš
- [ ] ç®æŽçŽè§çå建çé¢
- [ ] åéåé¢è§åæµè¯åèœ
- [ ] 宿¶è¿åºŠåéŠ
- [ ] 宿Žçæææ°æ®çæ¿
- [ ] æ¯ææ€ååä¿®æ¹
- [ ] å ³é®ææ çæ§
- [ ] åèŠè§åé 眮
- [ ] éŸè·¯è¿œèžªæ¯æ
- [ ] æ¥å¿å®æŽè®°åœ
è¿ä»œæž åå¯ä»¥åž®å©äœ å¿«éè¯äŒ°ç°æç³»ç»çå®åçšåºŠïŒææå¯Œæ°ç³»ç»ç讟计ã
ð¬ è¯è®º (0)