1117 lines
38 KiB
Go
1117 lines
38 KiB
Go
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
|
||
}
|