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

1117 lines
38 KiB
Go
Raw Normal View History

2026-09-30 17:01:18 +08:00
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/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(),
// 库内存相对路径(迭代-2026-10-09),读出侧统一拼绝对 URL,
// 否则后台 <a-image src> / <a href download> 用相对路径会 404
CodeUrl: absoluteURL(ctx, r["code_url"].String()),
2026-09-30 17:01:18 +08:00
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(),
// 库内存相对路径,读出侧拼绝对 URL(同上)
CodeUrl: absoluteURL(ctx, p["code_url"].String()),
2026-09-30 17:01:18 +08:00
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 {
2026-10-09 16:49:36 +08:00
// gerror.Wrap 保留底层 errcode/errmsg,便于区分「配置缺失 / 编码不对 / IP 白名单」
return nil, gerror.Wrap(err, "获取微信 access_token 失败;也可把绑定码 "+p["bind_token"].String()+" 直接发给用户,在绑定页手动输入")
2026-09-30 17:01:18 +08:00
}
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)
}
// 🔴 与活动码共用落盘设施(saveQrcodeImage,迭代-2026-10-09):落「图片库」manifest/static/
// 并返回绝对 URL,库内只存相对路径。
relPath, absURL, err := saveQrcodeImage(ctx, "project_"+strconv.FormatInt(p["id"].Int64(), 10)+".png", data)
if err != nil {
return nil, err
2026-09-30 17:01:18 +08:00
}
if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{
"code_url": relPath,
2026-09-30 17:01:18 +08:00
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
return &adminv1.AdminProjectQrcodeRes{CodeUrl: absURL}, nil
2026-09-30 17:01:18 +08:00
}
// 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
}