Files
qitongxue-api/internal/logic/project_flow.go

466 lines
17 KiB
Go
Raw Normal View History

2026-09-30 17:01:18 +08:00
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()
}