chore: 同步本地首页/用户管理/CKB 与小程序接口改动

Made-with: Cursor
This commit is contained in:
卡若
2026-04-07 09:07:04 +08:00
parent d48aba5047
commit b787fa3a1e
6 changed files with 277 additions and 36 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<GiftRow[]>(
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
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-gray-500"></span>
<span className="text-[10px] text-gray-500"></span>
<button type="button" className="text-[10px] text-[#38bdac] hover:underline"
onClick={() => setGifts(r => [...r, { id: `gift_${Date.now()}`, name: '新礼物', price: 1, emoji: '🎁', icon: '' }])}>+ </button>
</div>
{gifts.map((g, i) => (
<div key={`${g.id}-${i}`} className="flex items-center gap-1 mb-1">
<div key={g.id || `gift_idx_${i}`} className="flex items-center gap-1 mb-1">
<div className="flex flex-col shrink-0 border border-gray-700/60 rounded overflow-hidden bg-[#050f1c]">
<button type="button" title="上移" className="text-gray-500 hover:text-[#38bdac] disabled:opacity-25 disabled:pointer-events-none p-0 leading-none"
disabled={i === 0} onClick={() => swapGifts(i, i - 1)}>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button type="button" title="下移" className="text-gray-500 hover:text-[#38bdac] disabled:opacity-25 disabled:pointer-events-none p-0 leading-none border-t border-gray-700/60"
disabled={i === gifts.length - 1} onClick={() => swapGifts(i, i + 1)}>
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
<Input className="bg-[#050f1c] border-gray-700 text-white text-[10px] h-6 w-12" value={g.emoji} onChange={e => setGifts(r => r.map((x, j) => j === i ? { ...x, emoji: e.target.value } : x))} />
<Input className="bg-[#050f1c] border-gray-700 text-white text-[10px] h-6 flex-1" value={g.name} onChange={e => setGifts(r => r.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
<Input type="number" className="bg-[#050f1c] border-gray-700 text-white text-[10px] h-6 w-16" value={g.price} onChange={e => setGifts(r => r.map((x, j) => j === i ? { ...x, price: parseFloat(e.target.value) || 0 } : x))} />

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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|qtytipSource 为 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":