chore: 同步本地首页/用户管理/CKB 与小程序接口改动
Made-with: Cursor
This commit is contained in:
@@ -251,6 +251,37 @@ func ckbOpenGetPlanDetail(token string, planID int64) (string, error) {
|
||||
return result.Data.APIKey, nil
|
||||
}
|
||||
|
||||
// ckbOpenProbePlanReachable 仅用于连通性探测:GET /v1/plan/detail 返回 code==200 即认为计划可访问。
|
||||
// 与 ckbOpenGetPlanDetail 不同:不少计划详情接口不返回 apiKey(或字段名不同),不应因此判失败,更不应降级去调 update 触发「更新计划失败」误报。
|
||||
func ckbOpenProbePlanReachable(token string, planID int64) error {
|
||||
u := fmt.Sprintf("%s/v1/plan/detail?planId=%d", ckbOpenBaseURL, planID)
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构造计划详情请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求存客宝计划详情失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code != 200 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "获取计划详情失败"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ckbOpenGetDefaultDeviceID 获取默认设备 ID:拉设备列表,取第一个 memo 或 nickname 包含 "soul" 的设备;用于 deviceGroups 必填时的默认值
|
||||
func ckbOpenGetDefaultDeviceID(token string) (int64, error) {
|
||||
u := ckbOpenBaseURL + "/v1/devices?keyword=soul&page=1&limit=50"
|
||||
|
||||
@@ -827,7 +827,7 @@ func CKBPinnedPerson(c *gin.Context) {
|
||||
}
|
||||
|
||||
// AdminCKBPlanCheck GET /api/admin/ckb/plan-check 管理端-检查存客宝计划在线状态
|
||||
// 查询所有有 ckb_plan_id 的 Person,对每个计划调用存客宝获取状态(尝试启用以探测连通性)
|
||||
// 查询所有有 ckb_plan_id 的 Person,对每个计划调用存客宝计划详情做只读连通性探测(不调 update,避免误报「更新计划失败」)
|
||||
// 可选 ?personId=xxx 仅检查单个人物(超级个体行内刷新)
|
||||
func AdminCKBPlanCheck(c *gin.Context) {
|
||||
db := database.DB()
|
||||
@@ -858,17 +858,11 @@ func AdminCKBPlanCheck(c *gin.Context) {
|
||||
"ckbPlanId": p.CkbPlanID,
|
||||
"status": "unknown",
|
||||
}
|
||||
// 先用详情接口做连通性检测(不改动计划配置),避免 update 接口对部分计划返回“更新计划失败”造成误报。
|
||||
if _, detailErr := ckbOpenGetPlanDetail(token, p.CkbPlanID); detailErr == nil {
|
||||
item["status"] = "online"
|
||||
if probeErr := ckbOpenProbePlanReachable(token, p.CkbPlanID); probeErr != nil {
|
||||
item["status"] = "error"
|
||||
item["error"] = probeErr.Error()
|
||||
} else {
|
||||
// 兼容旧数据:详情失败时再降级尝试启用一次,双重探测提高兼容性。
|
||||
if enableErr := setCkbPlanEnabled(token, p.CkbPlanID, true); enableErr != nil {
|
||||
item["status"] = "error"
|
||||
item["error"] = fmt.Sprintf("详情探测失败: %v;启用探测失败: %v", detailErr, enableErr)
|
||||
} else {
|
||||
item["status"] = "online"
|
||||
}
|
||||
item["status"] = "online"
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
|
||||
@@ -460,9 +460,9 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
if pricePID == "" {
|
||||
pricePID = strings.TrimSpace(req.ProductID)
|
||||
}
|
||||
standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID)
|
||||
standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID, req.TipSource)
|
||||
if priceErr != nil && req.ProductType == "link_karuo_tip" {
|
||||
if fb, _, canonID, ferr := linkKaruoTipFallbackByAmount(db, pricePID, req.Amount); ferr == nil {
|
||||
if fb, _, canonID, ferr := linkKaruoTipFallbackByAmount(db, pricePID, req.Amount, req.TipSource); ferr == nil {
|
||||
standardPrice = fb
|
||||
priceErr = nil
|
||||
_, qty, qe := parseLinkKaruoGiftQty(pricePID)
|
||||
@@ -576,7 +576,6 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
case "match":
|
||||
description = "购买匹配次数"
|
||||
case "link_karuo_tip":
|
||||
root := loadMpConfigRoot(db)
|
||||
giftID := productID
|
||||
if strings.HasPrefix(giftID, "live_mic|") {
|
||||
parts := strings.Split(giftID, "|")
|
||||
@@ -587,7 +586,7 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
parts := strings.Split(giftID, "|")
|
||||
giftID = strings.TrimSpace(parts[0])
|
||||
}
|
||||
g, _ := findLinkKaruoGift(root, giftID)
|
||||
g, _ := findLinkKaruoGiftInList(mergedLinkKaruoGiftsForPay(db, req.TipSource), giftID)
|
||||
name := "打赏"
|
||||
if g != nil {
|
||||
if n, ok := g["name"].(string); ok && strings.TrimSpace(n) != "" {
|
||||
@@ -1425,12 +1424,129 @@ func linkKaruoGiftsList(root map[string]interface{}) []map[string]interface{} {
|
||||
return out
|
||||
}
|
||||
|
||||
func findLinkKaruoGift(root map[string]interface{}, giftID string) (map[string]interface{}, float64) {
|
||||
func cloneGiftMap(m map[string]interface{}) map[string]interface{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeGiftOverlayOrdered 顺序以置顶人物 overlay 为准,同 id 用 overlay 字段覆盖全局 base;仅存在于 base 的 id 按原顺序接在末尾
|
||||
func mergeGiftOverlayOrdered(base []map[string]interface{}, overlay []map[string]interface{}) []map[string]interface{} {
|
||||
if len(overlay) == 0 {
|
||||
return base
|
||||
}
|
||||
baseByID := make(map[string]map[string]interface{})
|
||||
for _, g := range base {
|
||||
id, _ := g["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
baseByID[id] = g
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
out := make([]map[string]interface{}, 0, len(overlay)+len(base))
|
||||
for _, og := range overlay {
|
||||
id, _ := og["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
var merged map[string]interface{}
|
||||
if b, ok := baseByID[id]; ok {
|
||||
merged = cloneGiftMap(b)
|
||||
} else {
|
||||
merged = make(map[string]interface{})
|
||||
}
|
||||
for k, v := range og {
|
||||
merged[k] = v
|
||||
}
|
||||
if _, ok := merged["id"]; !ok {
|
||||
merged["id"] = id
|
||||
}
|
||||
out = append(out, merged)
|
||||
seen[id] = true
|
||||
}
|
||||
for _, g := range base {
|
||||
id, _ := g["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
cm := cloneGiftMap(g)
|
||||
if cm != nil {
|
||||
out = append(out, cm)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadPinnedPersonHomeEntryGifts(db *gorm.DB) []map[string]interface{} {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var p model.Person
|
||||
if err := db.Where("is_pinned = ?", true).Order("updated_at DESC").First(&p).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
if p.HomeEntryConfig == nil || strings.TrimSpace(*p.HomeEntryConfig) == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if json.Unmarshal([]byte(*p.HomeEntryConfig), &m) != nil {
|
||||
return nil
|
||||
}
|
||||
rw, _ := m["linkKaruoReward"].(map[string]interface{})
|
||||
if rw == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := rw["gifts"]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
arr, ok := raw.([]interface{})
|
||||
if !ok || len(arr) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
g, ok := it.(map[string]interface{})
|
||||
if ok {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergedLinkKaruoGiftsForPay 首页横排礼物/上麦:全局 mp_config 与置顶人物 home_entry_config 按 id 合并,计价与小程序展示一致
|
||||
func mergedLinkKaruoGiftsForPay(db *gorm.DB, tipSource string) []map[string]interface{} {
|
||||
root := loadMpConfigRoot(db)
|
||||
base := linkKaruoGiftsList(root)
|
||||
ts := strings.TrimSpace(tipSource)
|
||||
if ts != "live_mic" && ts != "home_reward" {
|
||||
return base
|
||||
}
|
||||
ov := loadPinnedPersonHomeEntryGifts(db)
|
||||
if len(ov) == 0 {
|
||||
return base
|
||||
}
|
||||
return mergeGiftOverlayOrdered(base, ov)
|
||||
}
|
||||
|
||||
func findLinkKaruoGiftInList(gifts []map[string]interface{}, giftID string) (map[string]interface{}, float64) {
|
||||
giftID = strings.TrimSpace(giftID)
|
||||
if giftID == "" {
|
||||
return nil, 0
|
||||
}
|
||||
for _, g := range linkKaruoGiftsList(root) {
|
||||
for _, g := range gifts {
|
||||
id, _ := g["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == giftID || strings.EqualFold(id, giftID) {
|
||||
@@ -1444,6 +1560,10 @@ func findLinkKaruoGift(root map[string]interface{}, giftID string) (map[string]i
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
func findLinkKaruoGift(root map[string]interface{}, giftID string) (map[string]interface{}, float64) {
|
||||
return findLinkKaruoGiftInList(linkKaruoGiftsList(root), giftID)
|
||||
}
|
||||
|
||||
const linkKaruoAmountEpsilon = 0.051
|
||||
|
||||
// parseLinkKaruoGiftQty 解析 giftId|qty 或 live_mic|giftId|qty
|
||||
@@ -1469,11 +1589,10 @@ func parseLinkKaruoGiftQty(productID string) (giftID string, qty int64, err erro
|
||||
}
|
||||
|
||||
// linkKaruoTipFallbackByAmount 客户端礼物 id 与库不一致时,按金额唯一匹配后台档位并返回规范 id
|
||||
func linkKaruoTipFallbackByAmount(db *gorm.DB, productID string, clientAmount float64) (total float64, giftName string, canonicalID string, err error) {
|
||||
func linkKaruoTipFallbackByAmount(db *gorm.DB, productID string, clientAmount float64, tipSource string) (total float64, giftName string, canonicalID string, err error) {
|
||||
if clientAmount <= 0 {
|
||||
return 0, "", "", fmt.Errorf("支付金额无效")
|
||||
}
|
||||
root := loadMpConfigRoot(db)
|
||||
wantID, qty, err := parseLinkKaruoGiftQty(productID)
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
@@ -1484,7 +1603,7 @@ func linkKaruoTipFallbackByAmount(db *gorm.DB, productID string, clientAmount fl
|
||||
name string
|
||||
}
|
||||
var hits []hit
|
||||
for _, g := range linkKaruoGiftsList(root) {
|
||||
for _, g := range mergedLinkKaruoGiftsForPay(db, tipSource) {
|
||||
id, _ := g["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
@@ -1501,7 +1620,7 @@ func linkKaruoTipFallbackByAmount(db *gorm.DB, productID string, clientAmount fl
|
||||
}
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return 0, "", "", fmt.Errorf("打赏金额与后台档位不一致,请同步 mp_config 礼物后重试")
|
||||
return 0, "", "", fmt.Errorf("打赏金额与后台档位不一致,请检查全局礼物与置顶人物礼物配置后重试")
|
||||
}
|
||||
if len(hits) == 1 {
|
||||
h := hits[0]
|
||||
@@ -1523,13 +1642,12 @@ func linkKaruoTipFallbackByAmount(db *gorm.DB, productID string, clientAmount fl
|
||||
return 0, "", "", fmt.Errorf("后台存在同价多档礼物,请为每档设置不同价格或唯一 id")
|
||||
}
|
||||
|
||||
// linkKaruoTipPriceTotal 打赏标准价:普通 giftId|qty;上麦 live_mic|giftId|qty
|
||||
func linkKaruoTipPriceTotal(db *gorm.DB, productID string) (float64, error) {
|
||||
// linkKaruoTipPriceTotal 打赏标准价:普通 giftId|qty;上麦 live_mic|giftId|qty(tipSource 为 live_mic/home_reward 时合并置顶人物礼物价)
|
||||
func linkKaruoTipPriceTotal(db *gorm.DB, productID string, tipSource string) (float64, error) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
if productID == "" {
|
||||
return 0, fmt.Errorf("打赏缺少礼物标识")
|
||||
}
|
||||
root := loadMpConfigRoot(db)
|
||||
var giftID string
|
||||
var qty int64 = 1
|
||||
if strings.HasPrefix(productID, "live_mic|") {
|
||||
@@ -1552,7 +1670,8 @@ func linkKaruoTipPriceTotal(db *gorm.DB, productID string) (float64, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
_, price := findLinkKaruoGift(root, giftID)
|
||||
gifts := mergedLinkKaruoGiftsForPay(db, tipSource)
|
||||
_, price := findLinkKaruoGiftInList(gifts, giftID)
|
||||
if price <= 0 {
|
||||
return 0, fmt.Errorf("礼物不存在或未定价: %s", giftID)
|
||||
}
|
||||
@@ -1562,11 +1681,16 @@ func linkKaruoTipPriceTotal(db *gorm.DB, productID string) (float64, error) {
|
||||
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
|
||||
// productType: fullbook / vip / section / match / link_karuo_tip
|
||||
// productId: 章节购买时为章节 ID;打赏为 giftId|qty 或 live_mic|giftId|qty
|
||||
func getStandardPrice(db *gorm.DB, productType, productID string) (float64, error) {
|
||||
// tipSource 可选:live_mic / home_reward 时 link_karuo_tip 计价与置顶人物礼物配置合并
|
||||
func getStandardPrice(db *gorm.DB, productType, productID string, tipSource ...string) (float64, error) {
|
||||
productType = normalizePayProductType(productType)
|
||||
ts := ""
|
||||
if len(tipSource) > 0 {
|
||||
ts = strings.TrimSpace(tipSource[0])
|
||||
}
|
||||
// 打赏/上麦:显式分支 + 模糊识别双保险,避免误入 default 报「未知商品类型」
|
||||
if productType == "link_karuo_tip" || isLinkKaruoTipProductType(productType) {
|
||||
return linkKaruoTipPriceTotal(db, productID)
|
||||
return linkKaruoTipPriceTotal(db, productID, ts)
|
||||
}
|
||||
switch productType {
|
||||
case "fullbook", "vip", "match":
|
||||
|
||||
Reference in New Issue
Block a user