diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 46da6688..990cd2fc 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -67,8 +67,32 @@ function normalizeTipGifts(raw) { const base = DEFAULT_TIP_GIFTS if (!Array.isArray(raw) || raw.length === 0) return base.map((g) => ({ ...g })) return raw.map((row, i) => { - const d = base[Math.min(i, base.length - 1)] - const id = String(row.id != null ? row.id : d.id).trim() || d.id + const dFallback = base[Math.min(i, base.length - 1)] + const rn = String(row.name != null ? row.name : '').trim() + let id = row.id != null ? String(row.id).trim() : '' + let d = dFallback + // 无 id / 空 id 时禁止按下标套默认 id:后台顺序多为「人气票、小心心、嘉年华」,下标1会变成 gift_star, + // 导致中间显示小心心¥9.9 却带 gift_star 去支付变成¥29.8。优先按礼物名称、再按 micOrder 对齐默认档。 + if (!id) { + const byName = rn ? base.find((g) => g.name === rn) : undefined + if (byName) { + d = byName + id = byName.id + } else { + let mo = row.micOrder != null ? parseInt(String(row.micOrder), 10) : NaN + if (!Number.isNaN(mo)) { + const byOrder = base.find((g) => Number(g.micOrder) === mo) + if (byOrder) { + d = byOrder + id = byOrder.id + } + } + if (!id) { + d = dFallback + id = d.id + } + } + } const name = String(row.name != null ? row.name : d.name).trim() || d.name let price = parseFloat(row.price) if (Number.isNaN(price) || price <= 0) price = d.price diff --git a/soul-admin/src/pages/users/HomeEntryConfigTab.tsx b/soul-admin/src/pages/users/HomeEntryConfigTab.tsx index 46154ad4..7d0ad6c3 100644 --- a/soul-admin/src/pages/users/HomeEntryConfigTab.tsx +++ b/soul-admin/src/pages/users/HomeEntryConfigTab.tsx @@ -42,8 +42,34 @@ function parseGifts(raw: unknown): GiftRow[] { const out: GiftRow[] = [] for (let i = 0; i < raw.length; i++) { const g = asRecord(raw[i]) - const d = DEFAULT_GIFTS[Math.min(i, DEFAULT_GIFTS.length - 1)] - const id = String(g.id ?? d.id).trim() || d.id + const dFallback = DEFAULT_GIFTS[Math.min(i, DEFAULT_GIFTS.length - 1)] + const rn = String(g.name ?? '').trim() + let id = String(g.id ?? '').trim() + let d = dFallback + if (!id) { + const byName = rn ? DEFAULT_GIFTS.find((x) => x.name === rn) : undefined + if (byName) { + d = byName + id = byName.id + } else { + let mo = 999 + if (g.micOrder !== undefined && g.micOrder !== null && String(g.micOrder) !== '') { + const p = parseInt(String(g.micOrder), 10) + if (!Number.isNaN(p)) mo = p + } + if (mo !== 999) { + const byOrder = DEFAULT_GIFTS.find((x) => x.micOrder === mo) + if (byOrder) { + d = byOrder + id = byOrder.id + } + } + if (!id) { + d = dFallback + id = d.id + } + } + } const name = String(g.name ?? d.name).trim() || d.name let price = parseFloat(String(g.price ?? d.price)) if (Number.isNaN(price) || price <= 0) price = d.price diff --git a/soul-admin/src/pages/users/SuperIndividualTab.tsx b/soul-admin/src/pages/users/SuperIndividualTab.tsx index 4c305b09..54d9a283 100644 --- a/soul-admin/src/pages/users/SuperIndividualTab.tsx +++ b/soul-admin/src/pages/users/SuperIndividualTab.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/dialog' import { Crown, Tag, ArrowUpDown, RefreshCw, Save, X, Gift, Trash2, Plus, - Pin, ChevronDown, ChevronRight, Settings2, Webhook, Copy, Pencil, Stethoscope, + Pin, ChevronDown, ChevronRight, ChevronUp, Settings2, Webhook, Copy, Pencil, Stethoscope, } from 'lucide-react' import { get, put, post, del } from '@/api/client' import { PersonAddEditModal, type PersonFormData } from '@/pages/content/PersonAddEditModal' @@ -599,7 +599,15 @@ function ExpandedPanel({ member: m, onSaved, onCopyToken, onEditRole, onEditSort const [afterToast, setAfterToast] = useState(rw.afterPayToast || '支付成功,感谢支持!') const [gifts, setGifts] = useState( Array.isArray(rw.gifts) && rw.gifts.length > 0 - ? rw.gifts.map((g: any) => ({ id: String(g.id || ''), name: String(g.name || ''), price: Number(g.price) || 1, emoji: String(g.emoji || '🎁'), icon: String(g.icon || '') })) + ? rw.gifts.map((g: any) => { + let id = String(g.id || '').trim() + const nm = String(g.name || '').trim() + if (!id && nm) { + const hit = DEFAULT_GIFTS.find((x) => x.name === nm) + if (hit) id = hit.id + } + return { id, name: nm || '礼物', price: Number(g.price) || 1, emoji: String(g.emoji || '🎁'), icon: String(g.icon || '') } + }) : [...DEFAULT_GIFTS], ) const [micOn, setMicOn] = useState(normBool(sch.enabled)) @@ -610,11 +618,35 @@ function ExpandedPanel({ member: m, onSaved, onCopyToken, onEditRole, onEditSort const [micWh, setMicWh] = useState(sch.paidWebhookUrl || '') const [saving, setSaving] = useState(false) + const swapGifts = (i: number, j: number) => { + setGifts((rows) => { + if (i < 0 || j < 0 || i >= rows.length || j >= rows.length) return rows + const next = [...rows] + ;[next[i], next[j]] = [next[j], next[i]] + return next + }) + } + const save = async () => { setSaving(true) try { const hec: HomeEntryConfig = { - linkKaruoReward: { enabled: rewardEnabled, entryButtonText: entryText, modalTitle, modalSubtitle: modalSub, payButtonText: payBtn, afterPayToast: afterToast, gifts: gifts.map(g => ({ id: g.id, name: g.name, price: g.price, emoji: g.emoji, icon: g.icon })) }, + linkKaruoReward: { + enabled: rewardEnabled, + entryButtonText: entryText, + modalTitle, + modalSubtitle: modalSub, + payButtonText: payBtn, + afterPayToast: afterToast, + gifts: gifts.map((g) => { + let id = String(g.id || '').trim() + if (!id) { + const hit = DEFAULT_GIFTS.find((x) => x.name === String(g.name || '').trim()) + id = hit ? hit.id : '' + } + return { id, name: g.name, price: g.price, emoji: g.emoji, icon: g.icon } + }), + }, liveMicSchedule: { enabled: micOn, micStart, micEnd, micEmoji, micButtonText: micBtn, paidWebhookUrl: micWh.trim() }, } const r = await put<{ success?: boolean; error?: string }>('/api/db/persons/home-entry-config', { userId: m.id, homeEntryConfig: hec }) @@ -672,12 +704,22 @@ function ExpandedPanel({ member: m, onSaved, onCopyToken, onEditRole, onEditSort
- 礼物列表 + 礼物列表(上下箭头调整顺序)
{gifts.map((g, i) => ( -
+
+
+ + +
setGifts(r => r.map((x, j) => j === i ? { ...x, emoji: e.target.value } : x))} /> setGifts(r => r.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} /> setGifts(r => r.map((x, j) => j === i ? { ...x, price: parseFloat(e.target.value) || 0 } : x))} /> diff --git a/soul-api/internal/handler/ckb_open.go b/soul-api/internal/handler/ckb_open.go index 4f27d89b..7e46bbea 100644 --- a/soul-api/internal/handler/ckb_open.go +++ b/soul-api/internal/handler/ckb_open.go @@ -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" diff --git a/soul-api/internal/handler/db_person.go b/soul-api/internal/handler/db_person.go index 41a051b1..c6509792 100644 --- a/soul-api/internal/handler/db_person.go +++ b/soul-api/internal/handler/db_person.go @@ -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) } diff --git a/soul-api/internal/handler/miniprogram.go b/soul-api/internal/handler/miniprogram.go index 992553ff..b3fedc2e 100644 --- a/soul-api/internal/handler/miniprogram.go +++ b/soul-api/internal/handler/miniprogram.go @@ -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":