466 lines
17 KiB
Go
466 lines
17 KiB
Go
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()
|
||
}
|