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)。 // 复用年会域的 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 }