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

1117 lines
38 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"
"encoding/json"
"fmt"
"math/rand"
"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/gfile"
"github.com/gogf/gf/v2/os/gtime"
adminv1 "tool-api/api/admin/v1"
userv1 "tool-api/api/user/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 开发项目(迭代-2026-09-30 三期)
//
// 后台建单(生成绑定令牌)→ 管理端生成小程序码(wxacode.getUnlimited,
// scene = "p"+项目id+令牌,落 pkg-service/project-bind)→ 用户扫码绑定;
// 后台维护阶段 / 小节点 / 交付物(原型、UI 设计稿等图片),用户端只读查看。
//
// 关键约定:
// - 绑定令牌 10 位大写字母数字(去掉易混字符),scene 只含 id+token,不落个人信息;
// - 项目软删,阶段/节点/交付物跟随项目硬删(管理端内容,无独立保留价值);
// - 阶段状态变更 / 交付物上传 / 绑定成功 / 已交付 → 站内信通知绑定用户;
// - TZ-01:datetime 写库一律 gdb.Raw("NOW()")。
// ============================================================================
// projectBindPage 绑定页(扫码后进入的分包页面),与年会查座码图同一套落盘/静态路由。
const projectBindPage = "pkg-service/project-bind"
// bindTokenChars 去掉易混字符(0/O、1/I/L)的大写字母数字表
const bindTokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
func genBindToken() string {
b := make([]byte, 10)
for i := range b {
b[i] = bindTokenChars[rand.Intn(len(bindTokenChars))]
}
return string(b)
}
// parseBindScene 小程序码 scene("p"+id+token)→ token。非法返回空。
func parseBindScene(scene string) string {
s := strings.TrimSpace(scene)
if !strings.HasPrefix(s, "p") {
return ""
}
s = s[1:]
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
token := strings.TrimSpace(s[i:])
if len(token) < 6 || len(token) > 16 {
return ""
}
return token
}
// projectStageAgg 统计项目可见阶段的总数/已完成数
func projectStageAgg(ctx context.Context, projectId int64) (total, done int) {
total, _ = g.Model(consts.TableProjectStages).
Where("project_id", projectId).Where("is_enabled", 1).Count()
done, _ = g.Model(consts.TableProjectStages).
Where("project_id", projectId).Where("is_enabled", 1).Where("status", consts.StageStatusDone).Count()
return
}
// pushProjectUpdate 项目站内信(尽力而为;userId<=0 即未绑定,不推)
func pushProjectUpdate(ctx context.Context, userId int64, projectId int64, title, content string) {
if userId <= 0 {
return
}
PushMessage(ctx, userId, consts.MsgTypeProjectUpdate, title, content,
fmt.Sprintf("/pkg-service/project-detail?id=%d", projectId))
}
// ============================================================================
// C 端
// ============================================================================
// ProjectItemOf 列表行 → C 端输出(附阶段进度)
func projectItemOf(p gdb.Record) userv1.ProjectItem {
return userv1.ProjectItem{
Id: p["id"].Int64(),
Title: p["title"].String(),
Description: p["description"].String(),
PriceCents: p["price_cents"].Int64(),
PriceNote: p["price_note"].String(),
DueNote: p["due_note"].String(),
Status: p["status"].Int(),
StatusText: consts.ProjectStatusNameOf(p["status"].Int()),
UpdatedAt: timeStr(p["updated_at"].GTime()),
}
}
// ProjectMine 我的项目(当前用户绑定的,按更新时间倒序)
func ProjectMine(ctx context.Context) (*userv1.ProjectMineRes, error) {
records, err := g.Model(consts.TableServiceProjects).
Where("user_id", CtxUserId(ctx)).
OrderDesc("id").
All()
if err != nil {
return nil, err
}
// 入口图:每个项目取排序最靠前的交付物图片(阶段 sort → 文件 sort)
ids := make([]int64, 0, len(records))
for _, p := range records {
ids = append(ids, p["id"].Int64())
}
covers := projectCoverMap(ctx, ids)
list := make([]userv1.ProjectItem, 0, len(records))
for _, p := range records {
item := projectItemOf(p)
item.StageTotal, item.StageDone = projectStageAgg(ctx, p["id"].Int64())
item.CoverUrl = covers[p["id"].Int64()]
list = append(list, item)
}
return &userv1.ProjectMineRes{List: list}, nil
}
// projectCoverMap 项目 → 第一张交付物图(按阶段 sort、文件 sort 取最前)。
func projectCoverMap(ctx context.Context, ids []int64) map[int64]string {
out := map[int64]string{}
if len(ids) == 0 {
return out
}
files, err := g.Model(consts.TableProjectFiles+" f").
LeftJoin(consts.TableProjectStages+" s", "s.id=f.stage_id").
WhereIn("f.project_id", ids).
Fields("f.project_id, f.file_path").
Order("s.sort asc, s.id asc, f.sort asc, f.id asc").
All()
if err != nil {
return out
}
for _, f := range files {
pid := f["project_id"].Int64()
if _, ok := out[pid]; !ok && f["file_path"].String() != "" {
out[pid] = absoluteURL(ctx, f["file_path"].String())
}
}
return out
}
// ProjectDetail 项目详情(仅绑定用户可见;含阶段/节点/交付物全量)。
func ProjectDetail(ctx context.Context, id int64) (*userv1.ProjectDetailRes, error) {
p, err := g.Model(consts.TableServiceProjects).Where("id", id).One()
if err != nil {
return nil, err
}
if p.IsEmpty() || p["user_id"].Int64() != CtxUserId(ctx) {
return nil, gerror.New("项目不存在或未绑定到当前账号")
}
stages, err := projectStagesOut(ctx, id)
if err != nil {
return nil, err
}
pays, err := projectPaysOut(ctx, id)
if err != nil {
return nil, err
}
maintain, err := projectMaintainOut(ctx, p)
if err != nil {
return nil, err
}
res := &userv1.ProjectDetailRes{
ProjectItem: projectItemOf(p),
Stages: stages,
Pays: pays,
Maintain: maintain,
}
res.StageTotal, res.StageDone = projectStageAgg(ctx, id)
if p["status"].Int() == consts.ProjectStatusDone && p["accepted"].Int() == 0 {
res.CanAccept = 1
}
return res, nil
}
// projectPaysOut 分期列表(按期号升序)
func projectPaysOut(ctx context.Context, projectId int64) ([]userv1.ProjectPayItem, error) {
records, err := g.Model(consts.TableProjectPays).
Where("project_id", projectId).Order("seq asc, id asc").All()
if err != nil {
return nil, err
}
list := make([]userv1.ProjectPayItem, 0, len(records))
for _, r := range records {
list = append(list, userv1.ProjectPayItem{
Id: r["id"].Int64(),
Seq: r["seq"].Int(),
Percent: r["percent"].Int(),
AmountCents: r["amount_cents"].Int64(),
Trigger: r["trigger"].String(),
TriggerText: consts.PayTriggerNameOf(r["trigger"].String()),
Status: r["status"].Int(),
PaidAt: timeStr(r["paid_at"].GTime()),
})
}
return list, nil
}
// projectMaintainOut 维护期信息(未选模板 / 仅源码仍有展示,便于用户理解售后范围)
func projectMaintainOut(ctx context.Context, p gdb.Record) (*userv1.ProjectMaintainOut, error) {
planId := p["maintain_plan_id"].Int64()
if planId <= 0 {
return nil, nil
}
plan, err := g.Model(consts.TableMaintainPlans).Where("id", planId).One()
if err != nil {
return nil, err
}
if plan.IsEmpty() {
return nil, nil
}
return &userv1.ProjectMaintainOut{
PlanName: plan["name"].String(),
FreeYears: plan["free_years"].Int(),
YearlyFeeCents: plan["yearly_fee_cents"].Int64(),
SourceOnly: plan["source_only"].Int(),
Accepted: p["accepted"].Int(),
AcceptedAt: timeStr(p["accepted_at"].GTime()),
StartAt: timeStr(p["maintain_start_at"].GTime()),
PaidUntil: timeStr(p["maintain_paid_until"].GTime()),
}, nil
}
// ProjectAccept 用户确认验收:进入维护期(起点=验收当天;后台可调整起算日)。
// 维护费已缴至 = 起算日 + 免费年数(按年收费模板 free_years=0 → 验收后即进入首年缴费提醒窗口)。
func ProjectAccept(ctx context.Context, id int64) (*userv1.ProjectAcceptRes, error) {
p, err := g.Model(consts.TableServiceProjects).Where("id", id).One()
if err != nil {
return nil, err
}
if p.IsEmpty() || p["user_id"].Int64() != CtxUserId(ctx) {
return nil, gerror.New("项目不存在或未绑定到当前账号")
}
if p["status"].Int() != consts.ProjectStatusDone {
return nil, gerror.New("项目尚未交付,暂不能验收")
}
if p["accepted"].Int() == 1 {
return nil, gerror.New("该项目已验收过")
}
planId := p["maintain_plan_id"].Int64()
freeYears := 0
sourceOnly := 0
if planId > 0 {
plan, err := g.Model(consts.TableMaintainPlans).Where("id", planId).One()
if err == nil && !plan.IsEmpty() {
freeYears = plan["free_years"].Int()
sourceOnly = plan["source_only"].Int()
}
}
now := gtime.Now()
var paidUntil *gtime.Time
if sourceOnly != 1 {
paidUntil = now.AddDate(freeYears, 0, 0)
}
if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{
"accepted": 1,
"accepted_at": now,
"maintain_start_at": now,
"maintain_paid_until": paidUntil,
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
if sourceOnly == 1 {
pushProjectUpdate(ctx, p["user_id"].Int64(), id,
"验收成功",
"项目「"+p["title"].String()+"」已验收,源码交付完成。感谢信任!")
} else {
tail := "免费维护期至 " + paidUntil.Format("Y-m-d")
if freeYears <= 0 {
tail = "按年收费维护已开始,请留意年度维护费缴纳提醒"
}
pushProjectUpdate(ctx, p["user_id"].Int64(), id,
"验收成功,进入维护期",
"项目「"+p["title"].String()+"」验收通过,"+tail)
}
WriteAudit(ctx, AuditEntry{
Action: "service.project.accept",
TargetType: "service_project",
TargetId: auditId(id),
After: g.Map{"accepted": 1, "maintain_paid_until": paidUntil},
Result: consts.AuditResultSuccess,
})
res := &userv1.ProjectAcceptRes{MaintainStartAt: now.Format("Y-m-d")}
if paidUntil != nil {
res.PaidUntil = paidUntil.Format("Y-m-d")
}
return res, nil
}
// projectStagesOut 组装阶段树(节点 + 交付物,图片绝对 URL)
func projectStagesOut(ctx context.Context, projectId int64) ([]userv1.ProjectStageItem, error) {
stages, err := g.Model(consts.TableProjectStages).
Where("project_id", projectId).
Where("is_enabled", 1).
Order("sort asc, id asc").All()
if err != nil {
return nil, err
}
nodes, err := g.Model(consts.TableProjectNodes).
Where("project_id", projectId).
Order("sort asc, id asc").All()
if err != nil {
return nil, err
}
files, err := g.Model(consts.TableProjectFiles).
Where("project_id", projectId).
Order("sort asc, id asc").All()
if err != nil {
return nil, err
}
out := make([]userv1.ProjectStageItem, 0, len(stages))
for _, s := range stages {
stageId := s["id"].Int64()
stage := userv1.ProjectStageItem{
Id: stageId,
Title: s["title"].String(),
Status: s["status"].Int(),
StatusText: consts.StageStatusNameOf(s["status"].Int()),
}
for _, n := range nodes {
if n["stage_id"].Int64() != stageId {
continue
}
stage.Nodes = append(stage.Nodes, userv1.ProjectNodeItem{
Id: n["id"].Int64(),
Title: n["title"].String(),
IsDone: n["is_done"].Int(),
DoneAt: timeStr(n["done_at"].GTime()),
})
}
for _, f := range files {
if f["stage_id"].Int64() != stageId {
continue
}
stage.Files = append(stage.Files, userv1.ProjectFileItem{
Id: f["id"].Int64(),
FileName: f["file_name"].String(),
FileUrl: absoluteURL(ctx, f["file_path"].String()),
})
}
if stage.Nodes == nil {
stage.Nodes = []userv1.ProjectNodeItem{}
}
if stage.Files == nil {
stage.Files = []userv1.ProjectFileItem{}
}
out = append(out, stage)
}
return out, nil
}
// ProjectBind 扫码绑定:令牌来自小程序码 scene。已绑本人幂等;已被他人绑定报错。
func ProjectBind(ctx context.Context, token string) (*userv1.ProjectBindRes, error) {
token = strings.ToUpper(strings.TrimSpace(token))
if token == "" {
return nil, gerror.New("缺少绑定令牌")
}
p, err := g.Model(consts.TableServiceProjects).Where("bind_token", token).One()
if err != nil {
return nil, err
}
if p.IsEmpty() {
return nil, gerror.New("绑定码无效,请联系开发者确认")
}
userId := CtxUserId(ctx)
owner := p["user_id"].Int64()
if owner != 0 && owner != userId {
return nil, gerror.New("该项目已绑定到其他账号")
}
if owner != userId {
if _, err = g.Model(consts.TableServiceProjects).Where("id", p["id"].Int64()).Data(g.Map{
"user_id": userId,
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
pushProjectUpdate(ctx, userId, p["id"].Int64(),
"项目绑定成功",
"你的项目「"+p["title"].String()+"」已绑定到当前账号,进度与交付物会在这里同步。")
}
return &userv1.ProjectBindRes{ProjectId: p["id"].Int64()}, nil
}
// ============================================================================
// 管理端
// ============================================================================
// AdminProjectList 项目列表(含未绑定;带客户昵称与阶段进度)
func AdminProjectList(ctx context.Context, req *adminv1.AdminProjectListReq) (*adminv1.AdminProjectListRes, error) {
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
m := g.Model(consts.TableServiceProjects)
if kw := strings.TrimSpace(req.Keyword); kw != "" {
m = m.Where("title LIKE ?", "%"+kw+"%")
}
if req.Status != nil {
m = m.Where("status", *req.Status)
}
total, err := m.Count()
if err != nil {
return nil, err
}
records, err := m.Page(page, pageSize).OrderDesc("id").All()
if err != nil {
return nil, err
}
// 客户昵称 / 模板名映射
nicknames := map[int64]string{}
maintainNames := map[int64]string{}
payNames := map[int64]string{}
userIds := make([]int64, 0, len(records))
maintainIds := make([]int64, 0, len(records))
payIds := make([]int64, 0, len(records))
for _, r := range records {
if uid := r["user_id"].Int64(); uid > 0 {
userIds = append(userIds, uid)
}
if mid := r["maintain_plan_id"].Int64(); mid > 0 {
maintainIds = append(maintainIds, mid)
}
if pid := r["pay_plan_id"].Int64(); pid > 0 {
payIds = append(payIds, pid)
}
}
if len(userIds) > 0 {
rows, err := g.Model(consts.TableUsers).WhereIn("id", userIds).All()
if err == nil {
for _, u := range rows {
nicknames[u["id"].Int64()] = u["nickname"].String()
}
}
}
if len(maintainIds) > 0 {
rows, err := g.Model(consts.TableMaintainPlans).WhereIn("id", maintainIds).All()
if err == nil {
for _, m := range rows {
maintainNames[m["id"].Int64()] = m["name"].String()
}
}
}
if len(payIds) > 0 {
rows, err := g.Model(consts.TablePayPlans).WhereIn("id", payIds).All()
if err == nil {
for _, p := range rows {
payNames[p["id"].Int64()] = p["name"].String()
}
}
}
list := make([]adminv1.AdminProjectItem, 0, len(records))
for _, r := range records {
uid := r["user_id"].Int64()
item := adminv1.AdminProjectItem{
Id: r["id"].Int64(),
UserId: uid,
UserNickname: nicknames[uid],
Title: r["title"].String(),
Description: r["description"].String(),
PriceCents: r["price_cents"].Int64(),
PriceNote: r["price_note"].String(),
DueNote: r["due_note"].String(),
Status: r["status"].Int(),
StatusText: consts.ProjectStatusNameOf(r["status"].Int()),
BindToken: r["bind_token"].String(),
CodeUrl: r["code_url"].String(),
CreatedAt: timeStr(r["created_at"].GTime()),
UpdatedAt: timeStr(r["updated_at"].GTime()),
}
item.StageTotal, item.StageDone = projectStageAgg(ctx, item.Id)
item.MaintainPlanId = r["maintain_plan_id"].Int64()
item.PayPlanId = r["pay_plan_id"].Int64()
item.MaintainPlanName = maintainNames[item.MaintainPlanId]
item.PayPlanName = payNames[item.PayPlanId]
item.Accepted = r["accepted"].Int()
item.MaintainPaidUntil = timeStr(r["maintain_paid_until"].GTime())
item.MaintainDaysLeft = projectDaysLeft(r)
list = append(list, item)
}
return &adminv1.AdminProjectListRes{List: list, Total: total}, nil
}
// projectDaysLeft 距维护费到期天数;未启用维护期(未验收/仅源码/未设缴至)返回 -1。
// list 查询未 join 模板表,仅源码模板(yearly_fee=0)的项目会在已缴至为空时自然返回 -1。
func projectDaysLeft(p gdb.Record) int {
if p["accepted"].Int() != 1 {
return -1
}
paidUntil := p["maintain_paid_until"].GTime()
if paidUntil == nil {
return -1
}
return maintainDaysLeft(gtime.Now(), paidUntil)
}
// AdminProjectSave 新增/编辑项目。新建生成绑定令牌;userId 变更即时改绑;
// 状态切到「已交付」时推站内信。
func AdminProjectSave(ctx context.Context, req *adminv1.AdminProjectSaveReq) (*adminv1.AdminProjectSaveRes, error) {
title := strings.TrimSpace(req.Title)
if title == "" {
return nil, gerror.New("请填写项目名称")
}
// 模板校验(新建必选;编辑允许沿用原值)
if req.MaintainPlanId > 0 {
if cnt, _ := g.Model(consts.TableMaintainPlans).Where("id", req.MaintainPlanId).Where("is_enabled", 1).Count(); cnt == 0 {
return nil, gerror.New("维护模板不存在或已停用")
}
}
if req.PayPlanId > 0 {
if cnt, _ := g.Model(consts.TablePayPlans).Where("id", req.PayPlanId).Where("is_enabled", 1).Count(); cnt == 0 {
return nil, gerror.New("付款分期模板不存在或已停用")
}
}
data := g.Map{
"title": title,
"description": strings.TrimSpace(req.Description),
"price_cents": req.PriceCents,
"price_note": strings.TrimSpace(req.PriceNote),
"due_note": strings.TrimSpace(req.DueNote),
"status": req.Status,
"user_id": req.UserId,
}
if req.Id > 0 {
exist, err := g.Model(consts.TableServiceProjects).Where("id", req.Id).One()
if err != nil {
return nil, err
}
if exist.IsEmpty() {
return nil, gerror.New("项目不存在")
}
if req.MaintainPlanId > 0 {
data["maintain_plan_id"] = req.MaintainPlanId
}
if req.PayPlanId > 0 {
data["pay_plan_id"] = req.PayPlanId
}
data["updated_at"] = gdb.Raw("NOW()")
if _, err = g.Model(consts.TableServiceProjects).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
// 分期计划再生成:换了付款模板 → 必须没有已到账分期;金额变了 → 只重算未到账期数
if req.PayPlanId > 0 || req.PriceCents != exist["price_cents"].Int64() {
if err = projectRegeneratePays(ctx, req.Id, req.PriceCents); err != nil {
return nil, err
}
}
// 改绑 / 交付 的站内信
if req.UserId > 0 && req.UserId != exist["user_id"].Int64() {
pushProjectUpdate(ctx, req.UserId, req.Id,
"项目绑定成功",
"你的项目「"+title+"」已绑定到当前账号,进度与交付物会在这里同步。")
}
if req.Status == consts.ProjectStatusDone && exist["status"].Int() != consts.ProjectStatusDone {
pushProjectUpdate(ctx, req.UserId, req.Id,
"项目已交付",
"项目「"+title+"」已标记交付,感谢信任,售后随时找我们。")
}
WriteAudit(ctx, AuditEntry{
Action: "service.project.save",
TargetType: "service_project",
TargetId: auditId(req.Id),
Before: g.Map{"title": exist["title"].String(), "status": exist["status"].Int()},
After: g.Map{"title": title, "status": req.Status},
Result: consts.AuditResultSuccess,
})
return &adminv1.AdminProjectSaveRes{Id: req.Id, BindToken: exist["bind_token"].String()}, nil
}
token := genBindToken()
// 令牌唯一键兜底:极小概率撞表,重试一次
if cnt, _ := g.Model(consts.TableServiceProjects).Where("bind_token", token).Count(); cnt > 0 {
token = genBindToken()
}
if req.MaintainPlanId <= 0 || req.PayPlanId <= 0 {
return nil, gerror.New("请选择维护模板与付款分期模板")
}
data["maintain_plan_id"] = req.MaintainPlanId
data["pay_plan_id"] = req.PayPlanId
data["bind_token"] = token
data["created_at"] = gdb.Raw("NOW()")
data["updated_at"] = gdb.Raw("NOW()")
res, err := g.Model(consts.TableServiceProjects).Data(data).Insert()
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
if err = projectRegeneratePays(ctx, id, req.PriceCents); err != nil {
return nil, err
}
if req.UserId > 0 {
pushProjectUpdate(ctx, req.UserId, id,
"项目绑定成功",
"你的项目「"+title+"」已绑定到当前账号,进度与交付物会在这里同步。")
}
WriteAudit(ctx, AuditEntry{
Action: "service.project.save",
TargetType: "service_project",
TargetId: auditId(id),
After: g.Map{"title": title, "status": req.Status},
Result: consts.AuditResultSuccess,
})
return &adminv1.AdminProjectSaveRes{Id: id, BindToken: token}, nil
}
// projectRegeneratePays 按项目当前付款模板 × 金额生成分期计划。
// - 无已到账分期:整表重建(换模板 / 首次生成);
// - 已有到账分期:仅重算未到账期数的金额(最后一期吸收取整余额),不动已到账记录。
func projectRegeneratePays(ctx context.Context, projectId, priceCents int64) error {
p, err := g.Model(consts.TableServiceProjects).Where("id", projectId).One()
if err != nil {
return err
}
if p.IsEmpty() || p["pay_plan_id"].Int64() <= 0 {
return nil
}
plan, err := g.Model(consts.TablePayPlans).Where("id", p["pay_plan_id"].Int64()).One()
if err != nil {
return err
}
if plan.IsEmpty() {
return nil
}
steps, err := parsePaySchedule(plan["schedule"].String())
if err != nil {
return err
}
paidCnt, err := g.Model(consts.TableProjectPays).
Where("project_id", projectId).Where("status", consts.PayStatusPaid).Count()
if err != nil {
return err
}
if paidCnt > 0 {
// 只重算金额:所有期按各自比例算,取整差额(正负均可)归到最后一个未到账期。
// ⚠️ 差额 = 项目金额 − 全部期按比例之和(含已到账期),绝不能把已到账期漏掉,
// 否则最后一期会被错误地写成「项目金额 − 已到账合计」(线上踩过一次)。
all, err := g.Model(consts.TableProjectPays).
Where("project_id", projectId).Order("seq asc").All()
if err != nil {
return err
}
percentSum := int64(0)
for _, r := range all {
percentSum += priceCents * r["percent"].Int64() / 100
}
remainder := priceCents - percentSum
var lastUnpaid int64
for i := len(all) - 1; i >= 0; i-- {
if all[i]["status"].Int() == consts.PayStatusPending {
lastUnpaid = all[i]["id"].Int64()
break
}
}
for _, r := range all {
if r["status"].Int() != consts.PayStatusPending {
continue
}
amount := priceCents * r["percent"].Int64() / 100
if r["id"].Int64() == lastUnpaid {
amount += remainder
}
if _, err = g.Model(consts.TableProjectPays).Where("id", r["id"].Int64()).Data(g.Map{
"amount_cents": amount,
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return err
}
}
return nil
}
// 整表重建
if _, err = g.Model(consts.TableProjectPays).Where("project_id", projectId).Delete(); err != nil {
return err
}
allocated := int64(0)
for i, st := range steps {
amount := priceCents * st.Percent / 100
if i == len(steps)-1 {
amount = priceCents - allocated
}
allocated += amount
if _, err = g.Model(consts.TableProjectPays).Data(g.Map{
"project_id": projectId,
"seq": st.Seq,
"percent": st.Percent,
"amount_cents": amount,
"trigger": st.Trigger,
"status": consts.PayStatusPending,
"created_at": gdb.Raw("NOW()"),
"updated_at": gdb.Raw("NOW()"),
}).Insert(); err != nil {
return err
}
}
return nil
}
// payStep 分期计划单步
type payStep struct {
Seq int
Percent int64
Trigger string
}
// parsePaySchedule 解析分期模板 schedule JSON([{seq,percent,trigger}]),校验比例合计 100。
func parsePaySchedule(raw string) ([]payStep, error) {
var steps []payStep
if err := json.Unmarshal([]byte(raw), &steps); err != nil {
return nil, gerror.New("分期计划 JSON 不合法")
}
if len(steps) == 0 {
return nil, gerror.New("分期计划不能为空")
}
total := int64(0)
for _, st := range steps {
switch st.Trigger {
case consts.PayTriggerStart, consts.PayTriggerMidDemo,
consts.PayTriggerOnlineBefore, consts.PayTriggerOnlineAfter:
default:
return nil, gerror.New("分期触发节点不合法(start / mid_demo / online_before / online_after)")
}
if st.Percent <= 0 || st.Percent > 100 {
return nil, gerror.New("分期比例需在 1-100 之间")
}
total += st.Percent
}
if total != 100 {
return nil, gerror.New("分期比例合计必须为 100%")
}
return steps, nil
}
// AdminProjectDelete 删除项目(软删)及其阶段/节点/交付物(硬删)。
func AdminProjectDelete(ctx context.Context, id int64) (*adminv1.AdminProjectDeleteRes, error) {
exist, err := g.Model(consts.TableServiceProjects).Where("id", id).One()
if err != nil {
return nil, err
}
if exist.IsEmpty() {
return nil, gerror.New("项目不存在")
}
if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{
"deleted_at": gdb.Raw("NOW()"),
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
for _, table := range []string{consts.TableProjectStages, consts.TableProjectNodes, consts.TableProjectFiles} {
if _, err = g.Model(table).Where("project_id", id).Delete(); err != nil {
return nil, err
}
}
WriteAudit(ctx, AuditEntry{
Action: "service.project.delete",
TargetType: "service_project",
TargetId: auditId(id),
Before: g.Map{"title": exist["title"].String()},
Result: consts.AuditResultSuccess,
})
return &adminv1.AdminProjectDeleteRes{}, nil
}
// AdminProjectDetail 管理态详情(含全部阶段/节点/交付物,含停用阶段)
func AdminProjectDetail(ctx context.Context, id int64) (*adminv1.AdminProjectDetailRes, error) {
p, err := g.Model(consts.TableServiceProjects).Where("id", id).One()
if err != nil {
return nil, err
}
if p.IsEmpty() {
return nil, gerror.New("项目不存在")
}
item := adminv1.AdminProjectItem{
Id: p["id"].Int64(),
UserId: p["user_id"].Int64(),
Title: p["title"].String(),
Description: p["description"].String(),
PriceCents: p["price_cents"].Int64(),
PriceNote: p["price_note"].String(),
DueNote: p["due_note"].String(),
Status: p["status"].Int(),
StatusText: consts.ProjectStatusNameOf(p["status"].Int()),
BindToken: p["bind_token"].String(),
CodeUrl: p["code_url"].String(),
CreatedAt: timeStr(p["created_at"].GTime()),
UpdatedAt: timeStr(p["updated_at"].GTime()),
}
if item.UserId > 0 {
if u, err := g.Model(consts.TableUsers).Where("id", item.UserId).One(); err == nil && !u.IsEmpty() {
item.UserNickname = u["nickname"].String()
}
}
item.StageTotal, item.StageDone = projectStageAgg(ctx, id)
item.MaintainPlanId = p["maintain_plan_id"].Int64()
item.PayPlanId = p["pay_plan_id"].Int64()
item.Accepted = p["accepted"].Int()
item.AcceptedAt = timeStr(p["accepted_at"].GTime())
item.MaintainStartAt = timeStr(p["maintain_start_at"].GTime())
item.MaintainPaidUntil = timeStr(p["maintain_paid_until"].GTime())
item.MaintainDaysLeft = projectDaysLeft(p)
if item.MaintainPlanId > 0 {
if mp, err := g.Model(consts.TableMaintainPlans).Where("id", item.MaintainPlanId).One(); err == nil && !mp.IsEmpty() {
item.MaintainPlanName = mp["name"].String()
}
}
if item.PayPlanId > 0 {
if pp, err := g.Model(consts.TablePayPlans).Where("id", item.PayPlanId).One(); err == nil && !pp.IsEmpty() {
item.PayPlanName = pp["name"].String()
}
}
if pays, err := projectPaysOut(ctx, id); err == nil {
for _, py := range pays {
item.Pays = append(item.Pays, adminv1.AdminProjectPayItem{
Id: py.Id, Seq: py.Seq, Percent: py.Percent, AmountCents: py.AmountCents,
Trigger: py.Trigger, TriggerText: py.TriggerText, Status: py.Status,
PaidAt: py.PaidAt,
})
}
}
stages, err := g.Model(consts.TableProjectStages).
Where("project_id", id).Order("sort asc, id asc").All()
if err != nil {
return nil, err
}
nodes, err := g.Model(consts.TableProjectNodes).
Where("project_id", id).Order("sort asc, id asc").All()
if err != nil {
return nil, err
}
files, err := g.Model(consts.TableProjectFiles).
Where("project_id", id).Order("sort asc, id asc").All()
if err != nil {
return nil, err
}
for _, s := range stages {
stageId := s["id"].Int64()
stage := adminv1.AdminProjectStageItem{
Id: stageId,
Title: s["title"].String(),
Status: s["status"].Int(),
StatusText: consts.StageStatusNameOf(s["status"].Int()),
Sort: s["sort"].Int(),
IsEnabled: s["is_enabled"].Int(),
Nodes: []adminv1.AdminProjectNodeItem{},
Files: []adminv1.AdminProjectFileItem{},
}
for _, n := range nodes {
if n["stage_id"].Int64() != stageId {
continue
}
stage.Nodes = append(stage.Nodes, adminv1.AdminProjectNodeItem{
Id: n["id"].Int64(),
Title: n["title"].String(),
IsDone: n["is_done"].Int(),
Sort: n["sort"].Int(),
})
}
for _, f := range files {
if f["stage_id"].Int64() != stageId {
continue
}
stage.Files = append(stage.Files, adminv1.AdminProjectFileItem{
Id: f["id"].Int64(),
FileName: f["file_name"].String(),
FileUrl: absoluteURL(ctx, f["file_path"].String()),
})
}
item.Stages = append(item.Stages, stage)
}
return &adminv1.AdminProjectDetailRes{AdminProjectItem: item}, nil
}
// AdminProjectQrcode 生成/刷新绑定小程序码(wxacode.getUnlimited,scene=p<id><token>)。
// 复用年会域的 access_token 复用与码图落盘设施(QrCodeDir + /qrcode 静态路由)。
func AdminProjectQrcode(ctx context.Context, id int64) (*adminv1.AdminProjectQrcodeRes, error) {
p, err := g.Model(consts.TableServiceProjects).Where("id", id).One()
if err != nil {
return nil, err
}
if p.IsEmpty() {
return nil, gerror.New("项目不存在")
}
cfg := xpayConfig(ctx)
if cfg.AppId == "" || cfg.Secret == "" {
return nil, gerror.New("未配置 wx.appId / wx.appSecret,无法生成小程序码;也可把绑定码 " + p["bind_token"].String() + " 直接发给用户,在绑定页手动输入")
}
token, err := wxAccessToken(ctx)
if err != nil {
return nil, gerror.New("获取微信 access_token 失败,请稍后重试或检查 wx.appId / wx.appSecret 配置;也可把绑定码 " + p["bind_token"].String() + " 直接发给用户,在绑定页手动输入")
}
scene := "p" + strconv.FormatInt(p["id"].Int64(), 10) + p["bind_token"].String()
body := g.Map{
"scene": scene,
"page": projectBindPage,
"check_path": false, // 联调期页面可能未发布,避免路径校验失败
"env_version": "release",
}
resp, err := g.Client().ContentJson().Post(ctx, xpayAPIBase+"/wxa/getwxacodeunlimit?access_token="+token, body)
if err != nil {
return nil, gerror.Newf("调用 wxacode.getUnlimited 失败: %v", err)
}
defer resp.Close()
data := resp.ReadAll()
if len(data) == 0 {
return nil, gerror.New("生成小程序码失败:微信返回空响应")
}
if isJsonBytes(data) {
var we struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
_ = json.Unmarshal(data, &we)
return nil, gerror.Newf("生成小程序码失败:%d %s(页面未发布时请改用正式环境或令牌绑定)", we.ErrCode, we.ErrMsg)
}
if err = gfile.Mkdir(QrCodeDir); err != nil {
return nil, gerror.Newf("创建码图目录失败: %v", err)
}
fileName := "project_" + strconv.FormatInt(p["id"].Int64(), 10) + "_" + gtime.Now().Format("YmdHis") + ".png"
path := gfile.Join(QrCodeDir, fileName)
if err = gfile.PutBytes(path, data); err != nil {
return nil, gerror.Newf("写入码图失败: %v", err)
}
codeUrl := "/qrcode/" + fileName
if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{
"code_url": codeUrl,
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
return &adminv1.AdminProjectQrcodeRes{CodeUrl: codeUrl}, nil
}
// AdminProjectStageSave 新增/编辑阶段;状态变更推站内信。
func AdminProjectStageSave(ctx context.Context, req *adminv1.AdminProjectStageSaveReq) (*adminv1.AdminProjectStageSaveRes, error) {
title := strings.TrimSpace(req.Title)
if title == "" {
return nil, gerror.New("请填写阶段名称")
}
p, err := g.Model(consts.TableServiceProjects).Where("id", req.ProjectId).One()
if err != nil {
return nil, err
}
if p.IsEmpty() {
return nil, gerror.New("项目不存在")
}
data := g.Map{
"project_id": req.ProjectId,
"title": title,
"status": req.Status,
"sort": req.Sort,
"is_enabled": req.IsEnabled,
}
if req.Id > 0 {
exist, err := g.Model(consts.TableProjectStages).Where("id", req.Id).One()
if err != nil {
return nil, err
}
if exist.IsEmpty() || exist["project_id"].Int64() != req.ProjectId {
return nil, gerror.New("阶段不存在")
}
data["updated_at"] = gdb.Raw("NOW()")
if _, err = g.Model(consts.TableProjectStages).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
if req.Status != exist["status"].Int() {
pushProjectUpdate(ctx, p["user_id"].Int64(), req.ProjectId,
"阶段进度更新",
"项目「"+p["title"].String()+"」的阶段「"+title+"」已"+consts.StageStatusNameOf(req.Status)+"。")
}
return &adminv1.AdminProjectStageSaveRes{Id: req.Id}, nil
}
data["created_at"] = gdb.Raw("NOW()")
data["updated_at"] = gdb.Raw("NOW()")
res, err := g.Model(consts.TableProjectStages).Data(data).Insert()
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &adminv1.AdminProjectStageSaveRes{Id: id}, nil
}
// AdminProjectStageDelete 删除阶段(节点与交付物一并删除)。
func AdminProjectStageDelete(ctx context.Context, id int64) (*adminv1.AdminProjectStageDeleteRes, error) {
exist, err := g.Model(consts.TableProjectStages).Where("id", id).One()
if err != nil {
return nil, err
}
if exist.IsEmpty() {
return nil, gerror.New("阶段不存在")
}
if _, err = g.Model(consts.TableProjectStages).Where("id", id).Delete(); err != nil {
return nil, err
}
if _, err = g.Model(consts.TableProjectNodes).Where("stage_id", id).Delete(); err != nil {
return nil, err
}
if _, err = g.Model(consts.TableProjectFiles).Where("stage_id", id).Delete(); err != nil {
return nil, err
}
return &adminv1.AdminProjectStageDeleteRes{}, nil
}
// AdminProjectNodeSave 新增/编辑小节点(isDone 变更落完成时间)。
func AdminProjectNodeSave(ctx context.Context, req *adminv1.AdminProjectNodeSaveReq) (*adminv1.AdminProjectNodeSaveRes, error) {
title := strings.TrimSpace(req.Title)
if title == "" {
return nil, gerror.New("请填写节点内容")
}
stage, err := g.Model(consts.TableProjectStages).Where("id", req.StageId).One()
if err != nil {
return nil, err
}
if stage.IsEmpty() {
return nil, gerror.New("阶段不存在")
}
data := g.Map{
"project_id": stage["project_id"].Int64(),
"stage_id": req.StageId,
"title": title,
"is_done": req.IsDone,
"sort": req.Sort,
}
if req.Id > 0 {
exist, err := g.Model(consts.TableProjectNodes).Where("id", req.Id).One()
if err != nil {
return nil, err
}
if exist.IsEmpty() || exist["stage_id"].Int64() != req.StageId {
return nil, gerror.New("节点不存在")
}
if req.IsDone == 1 && exist["is_done"].Int() != 1 {
data["done_at"] = gdb.Raw("NOW()")
} else if req.IsDone != 1 {
data["done_at"] = nil
}
data["updated_at"] = gdb.Raw("NOW()")
if _, err = g.Model(consts.TableProjectNodes).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
return &adminv1.AdminProjectNodeSaveRes{Id: req.Id}, nil
}
if req.IsDone == 1 {
data["done_at"] = gdb.Raw("NOW()")
}
data["created_at"] = gdb.Raw("NOW()")
data["updated_at"] = gdb.Raw("NOW()")
res, err := g.Model(consts.TableProjectNodes).Data(data).Insert()
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &adminv1.AdminProjectNodeSaveRes{Id: id}, nil
}
// AdminProjectNodeDelete 删除小节点。
func AdminProjectNodeDelete(ctx context.Context, id int64) (*adminv1.AdminProjectNodeDeleteRes, error) {
if _, err := g.Model(consts.TableProjectNodes).Where("id", id).Delete(); err != nil {
return nil, err
}
return &adminv1.AdminProjectNodeDeleteRes{}, nil
}
// AdminProjectFileAdd 登记交付物(文件本体已由 /admin/upload 落盘)并推站内信。
func AdminProjectFileAdd(ctx context.Context, req *adminv1.AdminProjectFileAddReq) (*adminv1.AdminProjectFileAddRes, error) {
p, err := g.Model(consts.TableServiceProjects).Where("id", req.ProjectId).One()
if err != nil {
return nil, err
}
if p.IsEmpty() {
return nil, gerror.New("项目不存在")
}
stageTitle := "项目"
if req.StageId > 0 {
s, err := g.Model(consts.TableProjectStages).Where("id", req.StageId).One()
if err != nil {
return nil, err
}
if s.IsEmpty() || s["project_id"].Int64() != req.ProjectId {
return nil, gerror.New("阶段不存在")
}
stageTitle = s["title"].String()
}
res, err := g.Model(consts.TableProjectFiles).Data(g.Map{
"project_id": req.ProjectId,
"stage_id": req.StageId,
"file_name": strings.TrimSpace(req.FileName),
"file_path": normalizeToRelativePath(req.FilePath),
"created_at": gdb.Raw("NOW()"),
}).Insert()
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
pushProjectUpdate(ctx, p["user_id"].Int64(), req.ProjectId,
"新交付物上传",
"项目「"+p["title"].String()+"」的「"+stageTitle+"」上传了新交付物:"+strings.TrimSpace(req.FileName))
return &adminv1.AdminProjectFileAddRes{Id: id}, nil
}
// AdminProjectFileDelete 删除交付物登记(不动附件本体,附件管理页统一治理)。
func AdminProjectFileDelete(ctx context.Context, id int64) (*adminv1.AdminProjectFileDeleteRes, error) {
if _, err := g.Model(consts.TableProjectFiles).Where("id", id).Delete(); err != nil {
return nil, err
}
return &adminv1.AdminProjectFileDeleteRes{}, nil
}