Files
qitongxue-api/internal/logic/project_flow.go
2026-09-30 17:01:18 +08:00

466 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}