删除不再使用的文件,包括开放 API 鉴权规范文档、数据库迁移脚本和旧版图标组件,优化项目结构和资源管理。更新小程序代码以支持代付功能,增加代付分享弹窗和支付逻辑,提升用户体验。
This commit is contained in:
@@ -44,7 +44,7 @@ func ensurePersonByName(db *gorm.DB, name string) (token string, err error) {
|
||||
if db.Where("name = ?", name).First(&p).Error == nil {
|
||||
return p.Token, nil
|
||||
}
|
||||
created, err := createPersonMinimal(db, clean)
|
||||
created, err := createPersonMinimal(db, clean, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@ func previewContent(content string, percent int) string {
|
||||
limit = total
|
||||
}
|
||||
runes := []rune(content)
|
||||
return string(runes[:limit]) + "\n\n……(购买后阅读完整内容)"
|
||||
return string(runes[:limit]) + "\n\n……"
|
||||
}
|
||||
|
||||
// findChapterAndRespond 按条件查章节并返回统一格式
|
||||
|
||||
@@ -72,7 +72,7 @@ func ckbOpenGetToken() (string, error) {
|
||||
if msg == "" {
|
||||
msg = "存客宝鉴权失败"
|
||||
}
|
||||
return "", fmt.Errorf(msg)
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
return authResult.Data.Token, nil
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int
|
||||
if result.Message == "" {
|
||||
result.Message = "创建计划失败"
|
||||
}
|
||||
return 0, nil, ckbResponse, fmt.Errorf(result.Message)
|
||||
return 0, nil, ckbResponse, fmt.Errorf("%s", result.Message)
|
||||
}
|
||||
// 原始 data 转为 map 供响应展示
|
||||
createData = make(map[string]interface{})
|
||||
@@ -127,6 +127,49 @@ func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int
|
||||
return 0, createData, ckbResponse, fmt.Errorf("创建计划返回结果中缺少 planId")
|
||||
}
|
||||
|
||||
// ckbOpenUpdatePlan 调用 PUT /v1/plan/update 更新获客计划(用于停用/启用)
|
||||
// payload 至少包含 planId
|
||||
func ckbOpenUpdatePlan(token string, payload map[string]interface{}) (ckbResponse map[string]interface{}, err error) {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPut, ckbOpenBaseURL+"/v1/plan/update", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构造更新计划请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 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)
|
||||
ckbResponse = map[string]interface{}{
|
||||
"code": result.Code,
|
||||
"message": result.Message,
|
||||
"data": nil,
|
||||
}
|
||||
if len(result.Data) > 0 {
|
||||
var dataObj interface{}
|
||||
_ = json.Unmarshal(result.Data, &dataObj)
|
||||
ckbResponse["data"] = dataObj
|
||||
}
|
||||
if result.Code != 200 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "更新计划失败"
|
||||
}
|
||||
return ckbResponse, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return ckbResponse, nil
|
||||
}
|
||||
|
||||
// parseApiKeyFromCreateData 从 create 返回的 data 中解析 apiKey(若存客宝直接返回则复用,避免二次请求)
|
||||
func parseApiKeyFromCreateData(data map[string]interface{}) string {
|
||||
for _, key := range []string{"apiKey", "api_key"} {
|
||||
@@ -200,7 +243,7 @@ func ckbOpenGetPlanDetail(token string, planID int64) (string, error) {
|
||||
if result.Message == "" {
|
||||
result.Message = "获取计划详情失败"
|
||||
}
|
||||
return "", fmt.Errorf(result.Message)
|
||||
return "", fmt.Errorf("%s", result.Message)
|
||||
}
|
||||
if result.Data.APIKey == "" {
|
||||
return "", fmt.Errorf("计划详情中缺少 apiKey")
|
||||
@@ -341,7 +384,7 @@ func ckbOpenDeletePlan(token string, planID int64) error {
|
||||
if result.Message == "" {
|
||||
result.Message = "删除计划失败"
|
||||
}
|
||||
return fmt.Errorf(result.Message)
|
||||
return fmt.Errorf("%s", result.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -203,3 +203,86 @@ func CronSyncOrders(c *gin.Context) {
|
||||
func CronUnbindExpired(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// RunSyncVipCkbPlans 扫描已到期 VIP 用户,自动停用其绑定 Person 的存客宝计划
|
||||
// - 最佳努力:停用失败只记日志,不中断整体任务
|
||||
// - 幂等:重复执行不会产生额外副作用(计划已停用则仍然 update)
|
||||
func RunSyncVipCkbPlans(ctx context.Context, limit int) (scanned, disabled int, err error) {
|
||||
if limit < 1 {
|
||||
limit = 200
|
||||
}
|
||||
if limit > 2000 {
|
||||
limit = 2000
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 只处理“有过期日且已过期,并且绑定了 Person(user_id) 且有 planId”的用户
|
||||
// 说明:persons.user_id 为新增字段;历史未绑定的不在本任务处理范围内
|
||||
type row struct {
|
||||
UserID string `gorm:"column:user_id"`
|
||||
PlanID int64 `gorm:"column:ckb_plan_id"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
}
|
||||
rows := make([]row, 0)
|
||||
q := `
|
||||
SELECT u.id as user_id, p.ckb_plan_id, COALESCE(u.nickname,'') as nickname
|
||||
FROM users u
|
||||
INNER JOIN persons p ON p.user_id = u.id
|
||||
WHERE u.is_vip = 1
|
||||
AND u.vip_expire_date IS NOT NULL
|
||||
AND u.vip_expire_date <= NOW()
|
||||
AND p.ckb_plan_id > 0
|
||||
ORDER BY u.vip_expire_date ASC
|
||||
LIMIT ?`
|
||||
if err := db.Raw(q, limit).Scan(&rows).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
scanned = len(rows)
|
||||
if scanned == 0 {
|
||||
return scanned, 0, nil
|
||||
}
|
||||
|
||||
openToken, tokErr := ckbOpenGetToken()
|
||||
if tokErr != nil {
|
||||
// 没 token 直接失败,让 cron 重试(避免把用户标记成非 VIP 但计划未停用)
|
||||
return scanned, 0, tokErr
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return scanned, disabled, ctx.Err()
|
||||
default:
|
||||
}
|
||||
if r.PlanID <= 0 || r.UserID == "" {
|
||||
continue
|
||||
}
|
||||
if err := setCkbPlanEnabled(openToken, r.PlanID, false); err != nil {
|
||||
syncOrdersLogf("停用存客宝计划失败: userId=%s, planId=%d, nickname=%s, err=%v", r.UserID, r.PlanID, r.Nickname, err)
|
||||
continue
|
||||
}
|
||||
disabled++
|
||||
syncOrdersLogf("已停用存客宝计划: userId=%s, planId=%d, nickname=%s", r.UserID, r.PlanID, r.Nickname)
|
||||
|
||||
// 兜底清理脏标记:到期用户将 is_vip 置为 0(vip_expire_date 保留)
|
||||
_ = db.Model(&model.User{}).Where("id = ?", r.UserID).Update("is_vip", false).Error
|
||||
}
|
||||
return scanned, disabled, nil
|
||||
}
|
||||
|
||||
// CronSyncVipCkbPlans GET/POST /api/cron/sync-vip-ckb-plans
|
||||
// ?limit=200 每次最多处理 N 个到期用户
|
||||
func CronSyncVipCkbPlans(c *gin.Context) {
|
||||
limit := 200
|
||||
if s := strings.TrimSpace(c.Query("limit")); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
scanned, disabled, err := RunSyncVipCkbPlans(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "scanned": scanned, "disabled": disabled})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "scanned": scanned, "disabled": disabled, "limit": limit})
|
||||
}
|
||||
|
||||
@@ -226,7 +226,8 @@ func DBPersonSave(c *gin.Context) {
|
||||
}
|
||||
|
||||
// createPersonMinimal 仅按 name 创建 Person(含存客宝计划),供 autolink 复用
|
||||
func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
|
||||
// userID 可为空;用于“绑定用户 → 幂等创建”的场景
|
||||
func createPersonMinimal(db *gorm.DB, name string, userID string) (*model.Person, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("name 必填")
|
||||
@@ -271,6 +272,7 @@ func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
|
||||
}
|
||||
}
|
||||
newPerson := model.Person{
|
||||
UserID: strPtrIfNotEmpty(userID),
|
||||
PersonID: personID,
|
||||
Token: tok,
|
||||
Name: name,
|
||||
@@ -287,6 +289,103 @@ func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
|
||||
return &newPerson, nil
|
||||
}
|
||||
|
||||
func strPtrIfNotEmpty(s string) *string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// setCkbPlanEnabled 将存客宝计划置为启用/停用(最佳努力)
|
||||
func setCkbPlanEnabled(openToken string, planID int64, enabled bool) error {
|
||||
if planID <= 0 {
|
||||
return fmt.Errorf("planID 无效")
|
||||
}
|
||||
status := 0
|
||||
if enabled {
|
||||
status = 1
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"planId": planID,
|
||||
"status": status,
|
||||
"enabled": enabled,
|
||||
"scenario": 9, // 兜底:部分接口可能要求带 scenario,与 create 保持一致
|
||||
}
|
||||
_, err := ckbOpenUpdatePlan(openToken, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// ensurePersonForUser 确保用户对应的 Person 存在(用于超级个体开通成功后的自动创建)
|
||||
// 幂等规则:
|
||||
// 1) 优先按 persons.user_id 查;存在则必要时同步 name=nickname
|
||||
// 2) 若无 user_id 记录,则按 name=nickname 兜底复用;若复用成功且 user_id 为空则补绑
|
||||
// 3) 都不存在则创建(含 CKB 计划)
|
||||
//
|
||||
// 该逻辑为“最佳努力”,调用方不应因失败而阻断支付/权益激活。
|
||||
func ensurePersonForUser(db *gorm.DB, userID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("userID 不能为空")
|
||||
}
|
||||
var user model.User
|
||||
if err := db.Select("id", "nickname").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return fmt.Errorf("用户不存在: %w", err)
|
||||
}
|
||||
nickname := ""
|
||||
if user.Nickname != nil {
|
||||
nickname = strings.TrimSpace(*user.Nickname)
|
||||
}
|
||||
if nickname == "" {
|
||||
return fmt.Errorf("用户昵称为空,跳过创建 Person")
|
||||
}
|
||||
if !isValidNameOrLabel(nickname) {
|
||||
return fmt.Errorf("用户昵称不符合 Person.name 规则,跳过创建 Person")
|
||||
}
|
||||
|
||||
// 获取 CKB open token(仅在需要启用计划时使用;失败不阻断)
|
||||
openToken, _ := ckbOpenGetToken()
|
||||
|
||||
// 1) 按 user_id 查
|
||||
var p model.Person
|
||||
if err := db.Where("user_id = ?", userID).First(&p).Error; err == nil {
|
||||
// 同步展示名(跟随昵称)
|
||||
if strings.TrimSpace(p.Name) != nickname {
|
||||
db.Model(&p).Updates(map[string]interface{}{"name": nickname, "updated_at": time.Now()})
|
||||
}
|
||||
// 续费/恢复:若已有计划则尝试重新启用(最佳努力)
|
||||
if openToken != "" && p.CkbPlanID > 0 {
|
||||
_ = setCkbPlanEnabled(openToken, p.CkbPlanID, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2) 按 name 兜底复用
|
||||
var byName model.Person
|
||||
if err := db.Where("name = ?", nickname).First(&byName).Error; err == nil {
|
||||
// 若未绑定 user_id,补绑;并确保 name 为昵称
|
||||
updates := map[string]interface{}{}
|
||||
if byName.UserID == nil || strings.TrimSpace(*byName.UserID) == "" {
|
||||
updates["user_id"] = userID
|
||||
}
|
||||
if strings.TrimSpace(byName.Name) != nickname {
|
||||
updates["name"] = nickname
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
updates["updated_at"] = time.Now()
|
||||
db.Model(&byName).Updates(updates)
|
||||
}
|
||||
if openToken != "" && byName.CkbPlanID > 0 {
|
||||
_ = setCkbPlanEnabled(openToken, byName.CkbPlanID, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3) 创建
|
||||
_, err := createPersonMinimal(db, nickname, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func genPersonToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -360,7 +360,7 @@ func GiftPayDetail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if gpr.Status != "pending" && gpr.Status != "pending_pay" && gpr.Status != "paid" {
|
||||
if gpr.Status != "pending" && gpr.Status != "pending_pay" && gpr.Status != "paid" && gpr.Status != "refunded" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
@@ -431,6 +431,8 @@ func GiftPayDetail(c *gin.Context) {
|
||||
action = "pay"
|
||||
} else if gpr.Status == "paid" {
|
||||
action = "share"
|
||||
} else if gpr.Status == "refunded" {
|
||||
action = "refunded"
|
||||
} else if gpr.Status == "pending" {
|
||||
action = "share" // 旧版:待好友付
|
||||
}
|
||||
@@ -449,6 +451,8 @@ func GiftPayDetail(c *gin.Context) {
|
||||
} else {
|
||||
action = "redeem"
|
||||
}
|
||||
} else if gpr.Status == "refunded" {
|
||||
action = "refunded"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -675,6 +675,10 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
}
|
||||
expireDate := activateVIP(db, beneficiaryUserID, 365, vipActivatedAt)
|
||||
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", beneficiaryUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
|
||||
// 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力)
|
||||
if err := ensurePersonForUser(db, beneficiaryUserID); err != nil {
|
||||
fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, orderSn=%s, err=%v\n", beneficiaryUserID, orderSn, err)
|
||||
}
|
||||
} else if attach.ProductType == "match" {
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", beneficiaryUserID, orderSn)
|
||||
} else if attach.ProductType == "balance_recharge" {
|
||||
@@ -1096,6 +1100,10 @@ func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) {
|
||||
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
|
||||
case "vip":
|
||||
activateVIP(db, userID, 365, payTime)
|
||||
// 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力,不阻断权益)
|
||||
if err := ensurePersonForUser(db, userID); err != nil {
|
||||
fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, err=%v\n", userID, err)
|
||||
}
|
||||
case "balance_recharge":
|
||||
ConfirmBalanceRechargeByOrder(db, order)
|
||||
}
|
||||
|
||||
@@ -274,5 +274,12 @@ func AdminOrderRefund(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "退款成功但更新订单状态失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 代付批量订单退款:同步更新 gift_pay_requests 状态,避免小程序仍可分享/领取
|
||||
if order.GiftPayRequestID != nil && *order.GiftPayRequestID != "" {
|
||||
_ = db.Model(&model.GiftPayRequest{}).
|
||||
Where("id = ?", *order.GiftPayRequestID).
|
||||
Updates(map[string]interface{}{"status": "refunded"}).Error
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "退款成功"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user