package logic import ( "context" "fmt" "strconv" "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" adminv1 "tool-api/api/admin/v1" "tool-api/internal/consts" ) // ============================================================================ // 开发项目四期:维护模板 / 付款分期模板 / 分期到账 / 维护期 / 每日提醒扫描 //(迭代-2026-09-30,交付流程:需求提交 → 功能确认 → 支付定金 → 开发 → 验收 → 维护期) // ============================================================================ // ---------- 维护模板 ---------- // AdminMaintainPlanList 维护模板列表(含停用) func AdminMaintainPlanList(ctx context.Context) (*adminv1.AdminMaintainPlanListRes, error) { records, err := g.Model(consts.TableMaintainPlans).Order("sort asc, id asc").All() if err != nil { return nil, err } list := make([]adminv1.AdminMaintainPlanItem, 0, len(records)) for _, r := range records { list = append(list, adminv1.AdminMaintainPlanItem{ Id: r["id"].Int64(), Name: r["name"].String(), FreeYears: r["free_years"].Int(), YearlyFeeCents: r["yearly_fee_cents"].Int64(), SourceOnly: r["source_only"].Int(), Sort: r["sort"].Int(), IsEnabled: r["is_enabled"].Int(), Remark: r["remark"].String(), }) } return &adminv1.AdminMaintainPlanListRes{List: list}, nil } // AdminMaintainPlanSave 保存维护模板。仅源码模板不涉及免费年限与年费(统一清零)。 func AdminMaintainPlanSave(ctx context.Context, req *adminv1.AdminMaintainPlanSaveReq) (*adminv1.AdminMaintainPlanSaveRes, error) { name := strings.TrimSpace(req.Name) if name == "" { return nil, gerror.New("请填写模板名称") } if req.SourceOnly == 1 { req.YearlyFeeCents = 0 req.FreeYears = 0 } data := g.Map{ "name": name, "free_years": req.FreeYears, "yearly_fee_cents": req.YearlyFeeCents, "source_only": req.SourceOnly, "sort": req.Sort, "is_enabled": req.IsEnabled, "remark": strings.TrimSpace(req.Remark), } if req.Id > 0 { data["updated_at"] = gdb.Raw("NOW()") if _, err := g.Model(consts.TableMaintainPlans).Where("id", req.Id).Data(data).Update(); err != nil { return nil, err } return &adminv1.AdminMaintainPlanSaveRes{Id: req.Id}, nil } data["created_at"] = gdb.Raw("NOW()") data["updated_at"] = gdb.Raw("NOW()") res, err := g.Model(consts.TableMaintainPlans).Data(data).Insert() if err != nil { return nil, err } id, _ := res.LastInsertId() return &adminv1.AdminMaintainPlanSaveRes{Id: id}, nil } // AdminMaintainPlanDelete 删除维护模板(项目上的历史引用展示为「模板已删」)。 func AdminMaintainPlanDelete(ctx context.Context, id int64) (*adminv1.AdminMaintainPlanDeleteRes, error) { if _, err := g.Model(consts.TableMaintainPlans).Where("id", id).Delete(); err != nil { return nil, err } return &adminv1.AdminMaintainPlanDeleteRes{}, nil } // ---------- 付款分期模板 ---------- // AdminPayPlanList 分期模板列表(含停用) func AdminPayPlanList(ctx context.Context) (*adminv1.AdminPayPlanListRes, error) { records, err := g.Model(consts.TablePayPlans).Order("sort asc, id asc").All() if err != nil { return nil, err } list := make([]adminv1.AdminPayPlanItem, 0, len(records)) for _, r := range records { list = append(list, adminv1.AdminPayPlanItem{ Id: r["id"].Int64(), Name: r["name"].String(), Schedule: r["schedule"].String(), Sort: r["sort"].Int(), IsEnabled: r["is_enabled"].Int(), Remark: r["remark"].String(), }) } return &adminv1.AdminPayPlanListRes{List: list}, nil } // AdminPayPlanSave 保存分期模板(schedule JSON 校验:比例合计 100、触发节点合法)。 func AdminPayPlanSave(ctx context.Context, req *adminv1.AdminPayPlanSaveReq) (*adminv1.AdminPayPlanSaveRes, error) { name := strings.TrimSpace(req.Name) if name == "" { return nil, gerror.New("请填写模板名称") } if _, err := parsePaySchedule(req.Schedule); err != nil { return nil, err } data := g.Map{ "name": name, "schedule": strings.TrimSpace(req.Schedule), "sort": req.Sort, "is_enabled": req.IsEnabled, "remark": strings.TrimSpace(req.Remark), } if req.Id > 0 { data["updated_at"] = gdb.Raw("NOW()") if _, err := g.Model(consts.TablePayPlans).Where("id", req.Id).Data(data).Update(); err != nil { return nil, err } return &adminv1.AdminPayPlanSaveRes{Id: req.Id}, nil } data["created_at"] = gdb.Raw("NOW()") data["updated_at"] = gdb.Raw("NOW()") res, err := g.Model(consts.TablePayPlans).Data(data).Insert() if err != nil { return nil, err } id, _ := res.LastInsertId() return &adminv1.AdminPayPlanSaveRes{Id: id}, nil } // AdminPayPlanDelete 删除分期模板。 func AdminPayPlanDelete(ctx context.Context, id int64) (*adminv1.AdminPayPlanDeleteRes, error) { if _, err := g.Model(consts.TablePayPlans).Where("id", id).Delete(); err != nil { return nil, err } return &adminv1.AdminPayPlanDeleteRes{}, nil } // ---------- 分期到账 / 维护期 ---------- // AdminProjectPayMark 标记分期到账并通知用户。 func AdminProjectPayMark(ctx context.Context, req *adminv1.AdminProjectPayMarkReq) (*adminv1.AdminProjectPayMarkRes, error) { pay, err := g.Model(consts.TableProjectPays).Where("id", req.Id).One() if err != nil { return nil, err } if pay.IsEmpty() { return nil, gerror.New("分期不存在") } if pay["status"].Int() == consts.PayStatusPaid { return nil, gerror.New("该分期已是到账状态") } p, err := g.Model(consts.TableServiceProjects).Where("id", pay["project_id"].Int64()).One() if err != nil { return nil, err } if _, err = g.Model(consts.TableProjectPays).Where("id", req.Id).Data(g.Map{ "status": consts.PayStatusPaid, "paid_at": gdb.Raw("NOW()"), "paid_note": strings.TrimSpace(req.PaidNote), "updated_at": gdb.Raw("NOW()"), }).Update(); err != nil { return nil, err } pushProjectUpdate(ctx, p["user_id"].Int64(), pay["project_id"].Int64(), "付款已确认", fmt.Sprintf("项目「%s」第 %d 期(¥%s,%s)已确认到账。", p["title"].String(), pay["seq"].Int(), centsYuanText(pay["amount_cents"].Int64()), consts.PayTriggerNameOf(pay["trigger"].String()))) WriteAudit(ctx, AuditEntry{ Action: "service.project.pay.mark", TargetType: "service_project_pay", TargetId: auditId(req.Id), After: g.Map{"seq": pay["seq"].Int(), "amount_cents": pay["amount_cents"].Int64()}, Result: consts.AuditResultSuccess, }) return &adminv1.AdminProjectPayMarkRes{}, nil } // AdminProjectPayUnmark 撤销到账(误操作回退)。 func AdminProjectPayUnmark(ctx context.Context, id int64) (*adminv1.AdminProjectPayUnmarkRes, error) { if _, err := g.Model(consts.TableProjectPays).Where("id", id).Data(g.Map{ "status": consts.PayStatusPending, "paid_at": nil, "paid_note": "", "updated_at": gdb.Raw("NOW()"), }).Update(); err != nil { return nil, err } return &adminv1.AdminProjectPayUnmarkRes{}, nil } // AdminProjectMaintainRenew 维护费续收一年:已缴至顺延一年(从 max(已缴至, 今天) 起算)并通知用户。 func AdminProjectMaintainRenew(ctx context.Context, id int64) (*adminv1.AdminProjectMaintainRenewRes, error) { p, err := g.Model(consts.TableServiceProjects).Where("id", id).One() if err != nil { return nil, err } if p.IsEmpty() { return nil, gerror.New("项目不存在") } if p["accepted"].Int() != 1 { return nil, gerror.New("项目尚未验收,无维护期可续") } plan, err := g.Model(consts.TableMaintainPlans).Where("id", p["maintain_plan_id"].Int64()).One() if err != nil { return nil, err } if plan.IsEmpty() || plan["source_only"].Int() == 1 || plan["yearly_fee_cents"].Int64() <= 0 { return nil, gerror.New("该项目的维护模板不涉及年度维护费") } base := p["maintain_paid_until"].GTime() now := gtime.Now() if base == nil || base.Before(now) { base = now } paidUntil := base.AddDate(1, 0, 0) if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{ "maintain_paid_until": paidUntil, "last_maintain_remind_at": nil, "updated_at": gdb.Raw("NOW()"), }).Update(); err != nil { return nil, err } pushProjectUpdate(ctx, p["user_id"].Int64(), id, "维护费已续", fmt.Sprintf("项目「%s」的维护服务已续费一年(¥%s/年),有效期至 %s。", p["title"].String(), centsYuanText(plan["yearly_fee_cents"].Int64()), paidUntil.Format("Y-m-d"))) WriteAudit(ctx, AuditEntry{ Action: "service.project.maintain.renew", TargetType: "service_project", TargetId: auditId(id), After: g.Map{"paid_until": paidUntil.String()}, Result: consts.AuditResultSuccess, }) return &adminv1.AdminProjectMaintainRenewRes{PaidUntil: paidUntil.Format("Y-m-d")}, nil } // AdminProjectMaintainAdjust 调整维护期起算日(重算已缴至:起点 + 免费年数)。 func AdminProjectMaintainAdjust(ctx context.Context, req *adminv1.AdminProjectMaintainAdjustReq) (*adminv1.AdminProjectMaintainAdjustRes, error) { p, err := g.Model(consts.TableServiceProjects).Where("id", req.Id).One() if err != nil { return nil, err } if p.IsEmpty() { return nil, gerror.New("项目不存在") } startAt, err := gtime.StrToTime(req.StartAt + " 00:00:00") if err != nil { return nil, gerror.New("起算日期格式不合法") } freeYears := 0 sourceOnly := 0 if p["maintain_plan_id"].Int64() > 0 { plan, err := g.Model(consts.TableMaintainPlans).Where("id", p["maintain_plan_id"].Int64()).One() if err == nil && !plan.IsEmpty() { freeYears = plan["free_years"].Int() sourceOnly = plan["source_only"].Int() } } var paidUntil *gtime.Time if sourceOnly != 1 && p["accepted"].Int() == 1 { paidUntil = startAt.AddDate(freeYears, 0, 0) } if _, err = g.Model(consts.TableServiceProjects).Where("id", req.Id).Data(g.Map{ "maintain_start_at": startAt, "maintain_paid_until": paidUntil, "last_maintain_remind_at": nil, "updated_at": gdb.Raw("NOW()"), }).Update(); err != nil { return nil, err } WriteAudit(ctx, AuditEntry{ Action: "service.project.maintain.adjust", TargetType: "service_project", TargetId: auditId(req.Id), After: g.Map{"start_at": startAt.String(), "paid_until": paidUntil}, Result: consts.AuditResultSuccess, }) res := &adminv1.AdminProjectMaintainAdjustRes{} if paidUntil != nil { res.PaidUntil = paidUntil.Format("Y-m-d") } return res, nil } // ---------- 提醒设置 ---------- // AdminMaintainRemindGet 提醒设置读取(settings)。 func AdminMaintainRemindGet(ctx context.Context) (*adminv1.AdminMaintainRemindGetRes, error) { days, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.remindDays").One() notify, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.adminNotifyUserId").One() res := &adminv1.AdminMaintainRemindGetRes{RemindDays: 15} if !days.IsEmpty() { if v := days["v"].Int(); v > 0 { res.RemindDays = v } } if !notify.IsEmpty() { res.AdminNotifyUserId = notify["v"].Int64() } return res, nil } // AdminMaintainRemindSave 提醒设置保存(remindDays / adminNotifyUserId)。 func AdminMaintainRemindSave(ctx context.Context, req *adminv1.AdminMaintainRemindSaveReq) (*adminv1.AdminMaintainRemindSaveRes, error) { for _, kv := range []struct{ k, v string }{ {"service.maintain.remindDays", strconv.Itoa(req.RemindDays)}, {"service.maintain.adminNotifyUserId", strconv.FormatInt(req.AdminNotifyUserId, 10)}, } { if err := settingUpsert(ctx, kv.k, kv.v); err != nil { return nil, err } } return &adminv1.AdminMaintainRemindSaveRes{}, nil } // settingUpsert settings 单键写入(存在则更新,缺则补)。 func settingUpsert(ctx context.Context, k, v string) error { cnt, err := g.Model(consts.TableSettings).Where("k", k).Count() if err != nil { return err } if cnt > 0 { _, err = g.Model(consts.TableSettings).Where("k", k).Data(g.Map{ "v": v, "updated_at": gdb.Raw("NOW()"), }).Update() return err } _, err = g.Model(consts.TableSettings).Data(g.Map{ "k": k, "v": v, "updated_at": gdb.Raw("NOW()"), }).Insert() return err } // ============================================================================ // 维护费到期提醒:每天扫描(cmd.go 注册 cron) // - 范围:已验收 + 维护模板有年费(非仅源码)+ 已缴至距今天数 ≤ remindDays(含已逾期) // - 节奏:剩余 ≤3 天(含逾期)每天发;否则隔 3 天发一次(last_maintain_remind_at 判定) // - 通知:用户(站内信 + 订阅消息);超管(settings adminNotifyUserId 指定的小程序账号, // 同样站内信 + 订阅消息;未配置则只打日志——管理端开发项目页的待缴费横幅常驻可见) // ============================================================================ // ScanMaintainReminders 每日维护费提醒扫描。 func ScanMaintainReminders(ctx context.Context) { remindDays, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.remindDays").One() window := 15 if !remindDays.IsEmpty() { if v := remindDays["v"].Int(); v > 0 { window = v } } adminNotify, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.adminNotifyUserId").One() adminUserId := int64(0) if !adminNotify.IsEmpty() { adminUserId = adminNotify["v"].Int64() } records, err := g.Model(consts.TableServiceProjects+" p"). LeftJoin(consts.TableMaintainPlans+" mp", "mp.id=p.maintain_plan_id"). Where("p.accepted", 1). Where("p.status", consts.ProjectStatusDone). Where("mp.source_only", 0). Where("mp.yearly_fee_cents > 0"). Where("p.maintain_paid_until IS NOT NULL"). Fields("p.*, mp.name as plan_name, mp.yearly_fee_cents"). All() if err != nil { g.Log().Warningf(ctx, "[maintain-remind] 扫描失败: %v", err) return } now := gtime.Now() for _, r := range records { paidUntil := r["maintain_paid_until"].GTime() if paidUntil == nil { continue } daysLeft := maintainDaysLeft(now, paidUntil) if daysLeft > window { continue } last := r["last_maintain_remind_at"].GTime() if daysLeft > 3 && last != nil && now.Sub(last).Hours() < 72 { continue // 未到最后 3 天时,隔 3 天发一次 } title := "维护费缴纳提醒" content := fmt.Sprintf("项目「%s」的维护服务将于 %s 到期(剩余 %d 天),年度维护费 ¥%s,请及时缴纳。", r["title"].String(), paidUntil.Format("Y-m-d"), daysLeft, centsYuanText(r["yearly_fee_cents"].Int64())) if daysLeft < 0 { title = "维护费已到期" content = fmt.Sprintf("项目「%s」的维护服务已于 %s 到期,为不影响售后服务,请尽快缴纳年度维护费 ¥%s。", r["title"].String(), paidUntil.Format("Y-m-d"), centsYuanText(r["yearly_fee_cents"].Int64())) } link := fmt.Sprintf("/pkg-service/project-detail?id=%d", r["id"].Int64()) userId := r["user_id"].Int64() PushMessage(ctx, userId, consts.MsgTypeProjectUpdate, title, content, link) // 订阅消息(用户授权过才真正送达;模板未配置自动降级为站内信) if openid := userOpenid(ctx, userId); openid != "" { _ = SendProjectRemind(ctx, ProjectRemindSendReq{ Openid: openid, Title: title, Content: content, Page: link, }) } // 超管通知(settings 指定的小程序账号) if adminUserId > 0 && adminUserId != userId { PushMessage(ctx, adminUserId, consts.MsgTypeProjectUpdate, "【待缴费】"+title, content, link) if openid := userOpenid(ctx, adminUserId); openid != "" { _ = SendProjectRemind(ctx, ProjectRemindSendReq{ Openid: openid, Title: title, Content: content, Page: link, }) } } if _, err = g.Model(consts.TableServiceProjects).Where("id", r["id"].Int64()).Data(g.Map{ "last_maintain_remind_at": gdb.Raw("NOW()"), }).Update(); err != nil { g.Log().Warningf(ctx, "[maintain-remind] 记录提醒时间失败 projectId=%d: %v", r["id"].Int64(), err) } g.Log().Infof(ctx, "[maintain-remind] 已提醒 projectId=%d daysLeft=%d", r["id"].Int64(), daysLeft) } } // maintainDaysLeft 距维护费到期天数(向上取整;已逾期为负数)。 func maintainDaysLeft(now, paidUntil *gtime.Time) int { hours := paidUntil.Sub(now).Hours() if hours <= 0 { return -int(-hours/24) - 1 } d := int(hours / 24) if hours-float64(d*24) > 0 { d++ } return d } // userOpenid 查用户 openid(站内信收件人对应的订阅消息送达对象)。 func userOpenid(ctx context.Context, userId int64) string { if userId <= 0 { return "" } u, err := g.Model(consts.TableUsers).Where("id", userId).One() if err != nil || u.IsEmpty() { return "" } return u["openid"].String() }