diff --git a/api/admin/v1/attachment.go b/api/admin/v1/attachment.go new file mode 100644 index 0000000..b89e582 --- /dev/null +++ b/api/admin/v1/attachment.go @@ -0,0 +1,64 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 管理端附件管理契约(迭代-2026-09-29):upload / list / delete +// +// 所有上传(含用户端头像)都会登记到 attachments 表;本模块提供后台统一查看、 +// 上传日期、删除。删除为「记录软删 + 文件本体按引用检查」:仍被头像/封面等引用 +// 的文件只移除记录不删文件,返回 removed_file=false 说明这一点。 +// ============================================================================ + +// AdminUploadReq 管理端上传(multipart/form-data,文件字段名 file,表单字段 scene=cover|attachment) +type AdminUploadReq struct { + g.Meta `path:"/admin/upload" method:"post" mime:"multipart/form-data" tags:"admin" summary:"管理端上传文件"` + Scene string `v:"required|in:cover,attachment#请指定上传场景|不支持的上传场景" json:"scene"` +} +type AdminUploadRes struct { + Id int64 `json:"id"` // 附件登记 id(0 = 登记跳过/失败,不影响文件可用) + Url string `json:"url"` // 绝对 URL(按 site.base_url 现拼,直接展示用) + Path string `json:"path"` // 相对路径(/static/...)—— 商品封面等库内只存这个 + FileName string `json:"file_name"` // 原始文件名 + Ext string `json:"ext"` // 扩展名(小写含点) + SizeBytes int64 `json:"size_bytes"` // 字节 +} + +// AdminAttachmentItem 附件列表项(响应 snake_case) +type AdminAttachmentItem struct { + Id int64 `json:"id"` + Scene string `json:"scene"` // avatar / cover / attachment + FileName string `json:"file_name"` + FilePath string `json:"file_path"` + Url string `json:"url"` // 绝对 URL(现拼) + Ext string `json:"ext"` + SizeBytes int64 `json:"size_bytes"` + Md5 string `json:"md5"` + UploaderId int64 `json:"uploader_id"` + UploaderType string `json:"uploader_type"` // admin / user + IsReferenced bool `json:"is_referenced"` // 是否仍被头像/封面/其他记录引用 + IsImage bool `json:"is_image"` // 是否图片(可缩略预览) + CreatedAt string `json:"created_at"` // 上传日期 +} + +// AdminAttachmentListReq 附件列表 +type AdminAttachmentListReq struct { + g.Meta `path:"/admin/attachment/list" method:"get" tags:"admin" summary:"附件列表"` + Keyword string `json:"keyword"` // 文件名模糊 + Scene string `json:"scene"` // 空=全部;avatar/cover/attachment + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type AdminAttachmentListRes struct { + List []AdminAttachmentItem `json:"list"` + Total int `json:"total"` +} + +// AdminAttachmentDeleteReq 删除附件(记录软删;文件本体无引用时才落盘删除) +type AdminAttachmentDeleteReq struct { + g.Meta `path:"/admin/attachment/delete" method:"post" tags:"admin" summary:"删除附件"` + Id int64 `v:"required" json:"id"` +} +type AdminAttachmentDeleteRes struct { + RemovedFile bool `json:"removed_file"` // false = 文件仍被引用,仅移除了登记记录 +} diff --git a/api/admin/v1/goods.go b/api/admin/v1/goods.go new file mode 100644 index 0000000..db08323 --- /dev/null +++ b/api/admin/v1/goods.go @@ -0,0 +1,89 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 管理端软件开发服务契约(迭代-2026-09-29):list / detail / save / delete +// +// 封面 cover_path 库内存相对路径(/static/...);响应里 cover_url 为按 +// site.base_url 拼接的绝对地址。content_md 为 Markdown 原文,管理端编辑预览、 +// 小程序端解析渲染(富文本节点),后端只存原文不渲染。 +// ============================================================================ + +// AdminGoodsItem 服务列表项(响应 snake_case,不含 content_md —— 详情接口单独取) +type AdminGoodsItem struct { + Id int64 `json:"id"` + Category int `json:"category"` + CategoryName string `json:"category_name"` + Title string `json:"title"` + Summary string `json:"summary"` + CoverPath string `json:"cover_path"` + CoverUrl string `json:"cover_url"` + PriceCents int64 `json:"price_cents"` + PriceNote string `json:"price_note"` + Sort int `json:"sort"` + IsEnabled int `json:"is_enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// AdminGoodsListReq 服务列表 +type AdminGoodsListReq struct { + g.Meta `path:"/admin/goods/list" method:"get" tags:"admin" summary:"软件开发服务列表"` + Keyword string `json:"keyword"` + Category int `json:"category"` // 0=全部 + IsEnabled *int `json:"isEnabled"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + SortBy string `json:"sortBy"` + SortOrder string `json:"sortOrder"` // asc / desc +} +type AdminGoodsListRes struct { + List []AdminGoodsItem `json:"list"` + Total int `json:"total"` +} + +// AdminGoodsDetailReq 服务详情(编辑回填,含 content_md) +type AdminGoodsDetailReq struct { + g.Meta `path:"/admin/goods/detail" method:"get" tags:"admin" summary:"软件开发服务详情"` + Id int64 `v:"required" json:"id"` +} +type AdminGoodsDetailRes struct { + Id int64 `json:"id"` + Category int `json:"category"` + CategoryName string `json:"category_name"` + Title string `json:"title"` + Summary string `json:"summary"` + ContentMd string `json:"content_md"` + CoverPath string `json:"cover_path"` + CoverUrl string `json:"cover_url"` + PriceCents int64 `json:"price_cents"` + PriceNote string `json:"price_note"` + Sort int `json:"sort"` + IsEnabled int `json:"is_enabled"` +} + +// AdminGoodsSaveReq 新增/编辑服务(id=0 新建)。coverPath 传上传接口返回的 path(相对路径)。 +type AdminGoodsSaveReq struct { + g.Meta `path:"/admin/goods/save" method:"post" tags:"admin" summary:"保存软件开发服务"` + Id int64 `json:"id"` + Category int `v:"required|in:1,2,3,4#请选择分类|分类不合法" json:"category"` + Title string `v:"required#请填写服务名称" json:"title"` + Summary string `v:"length:0,500#简介最多 500 字" json:"summary"` + ContentMd string `json:"contentMd"` + PriceCents int64 `v:"min:0#价格不能为负" json:"priceCents"` + PriceNote string `v:"length:0,64#价格备注最多 64 字" json:"priceNote"` + CoverPath string `json:"coverPath"` + Sort int `json:"sort"` + IsEnabled int `json:"isEnabled"` +} +type AdminGoodsSaveRes struct { + Id int64 `json:"id"` +} + +// AdminGoodsDeleteReq 删除服务(软删除) +type AdminGoodsDeleteReq struct { + g.Meta `path:"/admin/goods/delete" method:"post" tags:"admin" summary:"删除软件开发服务"` + Id int64 `v:"required" json:"id"` +} +type AdminGoodsDeleteRes struct{} diff --git a/api/admin/v1/project.go b/api/admin/v1/project.go new file mode 100644 index 0000000..4630c82 --- /dev/null +++ b/api/admin/v1/project.go @@ -0,0 +1,341 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 管理端开发项目契约(迭代-2026-09-30 三期) +// +// 建单 → 生成绑定小程序码(scene 携带 bind_token)→ 用户扫码绑定; +// 阶段/小节点/交付物(原型、UI 设计稿)均为即时保存接口(管理端逐项操作)。 +// 阶段状态变更 / 交付物上传 / 交付 会向绑定用户推站内信。 +// ============================================================================ + +// AdminProjectPayItem 项目分期 +type AdminProjectPayItem struct { + Id int64 `json:"id"` + Seq int `json:"seq"` + Percent int `json:"percent"` + AmountCents int64 `json:"amount_cents"` + Trigger string `json:"trigger"` + TriggerText string `json:"trigger_text"` + Status int `json:"status"` + PaidAt string `json:"paid_at"` + PaidNote string `json:"paid_note"` +} + +// AdminProjectItem 项目列表项 +type AdminProjectItem struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + UserNickname string `json:"user_nickname"` + Title string `json:"title"` + Description string `json:"description"` + PriceCents int64 `json:"price_cents"` + PriceNote string `json:"price_note"` + DueNote string `json:"due_note"` + Status int `json:"status"` + StatusText string `json:"status_text"` + StageTotal int `json:"stage_total"` + StageDone int `json:"stage_done"` + BindToken string `json:"bind_token"` + CodeUrl string `json:"code_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Stages []AdminProjectStageItem `json:"stages"` + + MaintainPlanId int64 `json:"maintain_plan_id"` + PayPlanId int64 `json:"pay_plan_id"` + MaintainPlanName string `json:"maintain_plan_name"` + PayPlanName string `json:"pay_plan_name"` + Accepted int `json:"accepted"` + AcceptedAt string `json:"accepted_at"` + MaintainStartAt string `json:"maintain_start_at"` + MaintainPaidUntil string `json:"maintain_paid_until"` + MaintainDaysLeft int `json:"maintain_days_left"` // 距下次缴费天数(未启用维护为 -1) + Pays []AdminProjectPayItem `json:"pays"` +} + +// AdminProjectListReq 项目列表 +type AdminProjectListReq struct { + g.Meta `path:"/admin/service/project/list" method:"get" tags:"admin" summary:"开发项目列表"` + Keyword string `json:"keyword"` + Status *int `json:"status"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type AdminProjectListRes struct { + List []AdminProjectItem `json:"list"` + Total int `json:"total"` +} + +// AdminProjectSaveReq 新增/编辑项目(id=0 新建并生成绑定令牌;userId>0 直接绑定) +type AdminProjectSaveReq struct { + g.Meta `path:"/admin/service/project/save" method:"post" tags:"admin" summary:"保存开发项目"` + Id int64 `json:"id"` + Title string `v:"required#请填写项目名称" json:"title"` + Description string `v:"length:0,1000#需求描述最多 1000 字" json:"description"` + PriceCents int64 `v:"min:0#金额不能为负" json:"priceCents"` + PriceNote string `v:"length:0,64#金额备注最多 64 字" json:"priceNote"` + DueNote string `v:"length:0,64#交付周期最多 64 字" json:"dueNote"` + Status int `v:"in:1,2,3#状态不合法" json:"status"` + UserId int64 `json:"userId"` // 编辑时可直接改绑(填 0 = 解绑待扫码) + MaintainPlanId int64 `json:"maintainPlanId"` // 维护模板(0=未选) + PayPlanId int64 `json:"payPlanId"` // 付款分期模板(0=未选) +} +type AdminProjectSaveRes struct { + Id int64 `json:"id"` + BindToken string `json:"bind_token"` // 新建时返回,供前端直接弹码 +} + +// AdminProjectDeleteReq 删除项目(软删除;阶段/节点/交付物一并硬删) +type AdminProjectDeleteReq struct { + g.Meta `path:"/admin/service/project/delete" method:"post" tags:"admin" summary:"删除开发项目"` + Id int64 `v:"required" json:"id"` +} +type AdminProjectDeleteRes struct{} + +// AdminProjectStageItem 阶段(含节点与文件,管理端详情用) +type AdminProjectStageItem struct { + Id int64 `json:"id"` + Title string `json:"title"` + Status int `json:"status"` + StatusText string `json:"status_text"` + Sort int `json:"sort"` + IsEnabled int `json:"is_enabled"` + Nodes []AdminProjectNodeItem `json:"nodes"` + Files []AdminProjectFileItem `json:"files"` +} + +// AdminProjectNodeItem 小节点 +type AdminProjectNodeItem struct { + Id int64 `json:"id"` + Title string `json:"title"` + IsDone int `json:"is_done"` + Sort int `json:"sort"` +} + +// AdminProjectFileItem 交付物(绝对 URL 直显) +type AdminProjectFileItem struct { + Id int64 `json:"id"` + FileName string `json:"file_name"` + FileUrl string `json:"file_url"` +} + +// AdminProjectDetailReq 项目详情(管理态全量:阶段/节点/文件) +type AdminProjectDetailReq struct { + g.Meta `path:"/admin/service/project/detail" method:"get" tags:"admin" summary:"开发项目详情"` + Id int64 `v:"required" json:"id"` +} +type AdminProjectDetailRes struct { + AdminProjectItem +} + +// AdminProjectQrcodeReq 生成/重取绑定小程序码(微信 getUnlimited,scene=项目+令牌) +type AdminProjectQrcodeReq struct { + g.Meta `path:"/admin/service/project/qrcode" method:"post" tags:"admin" summary:"生成项目绑定小程序码"` + Id int64 `v:"required" json:"id"` +} +type AdminProjectQrcodeRes struct { + CodeUrl string `json:"code_url"` +} + +// AdminProjectStageSaveReq 新增/编辑阶段 +type AdminProjectStageSaveReq struct { + g.Meta `path:"/admin/service/project/stage/save" method:"post" tags:"admin" summary:"保存项目阶段"` + Id int64 `json:"id"` + ProjectId int64 `v:"required" json:"projectId"` + Title string `v:"required#请填写阶段名称" json:"title"` + Status int `v:"in:0,1,2#状态不合法" json:"status"` + Sort int `json:"sort"` + IsEnabled int `json:"isEnabled"` +} +type AdminProjectStageSaveRes struct { + Id int64 `json:"id"` +} + +// AdminProjectStageDeleteReq 删除阶段(其节点与交付物一并删除) +type AdminProjectStageDeleteReq struct { + g.Meta `path:"/admin/service/project/stage/delete" method:"post" tags:"admin" summary:"删除项目阶段"` + Id int64 `v:"required" json:"id"` +} +type AdminProjectStageDeleteRes struct{} + +// AdminProjectNodeSaveReq 新增/编辑小节点(isDone 变更即时生效) +type AdminProjectNodeSaveReq struct { + g.Meta `path:"/admin/service/project/node/save" method:"post" tags:"admin" summary:"保存阶段小节点"` + Id int64 `json:"id"` + StageId int64 `v:"required" json:"stageId"` + Title string `v:"required#请填写节点内容" json:"title"` + IsDone int `json:"isDone"` + Sort int `json:"sort"` +} +type AdminProjectNodeSaveRes struct { + Id int64 `json:"id"` +} + +// AdminProjectNodeDeleteReq 删除小节点 +type AdminProjectNodeDeleteReq struct { + g.Meta `path:"/admin/service/project/node/delete" method:"post" tags:"admin" summary:"删除阶段小节点"` + Id int64 `v:"required" json:"id"` +} +type AdminProjectNodeDeleteRes struct{} + +// AdminProjectFileAddReq 登记交付物(文件本体先经 /admin/upload 上传,此处只落 path) +type AdminProjectFileAddReq struct { + g.Meta `path:"/admin/service/project/file/add" method:"post" tags:"admin" summary:"登记项目交付物"` + ProjectId int64 `v:"required" json:"projectId"` + StageId int64 `json:"stageId"` + FileName string `v:"required#请填写文件名" json:"fileName"` + FilePath string `v:"required#缺少文件路径" json:"filePath"` +} +type AdminProjectFileAddRes struct { + Id int64 `json:"id"` +} + +// AdminProjectFileDeleteReq 删除交付物登记 +type AdminProjectFileDeleteReq struct { + g.Meta `path:"/admin/service/project/file/delete" method:"post" tags:"admin" summary:"删除项目交付物"` + Id int64 `v:"required" json:"id"` +} +type AdminProjectFileDeleteRes struct{} + +// ---------- 维护模板 ---------- + +// AdminMaintainPlanItem 维护模板 +type AdminMaintainPlanItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + FreeYears int `json:"free_years"` + YearlyFeeCents int64 `json:"yearly_fee_cents"` + SourceOnly int `json:"source_only"` + Sort int `json:"sort"` + IsEnabled int `json:"is_enabled"` + Remark string `json:"remark"` +} + +// AdminMaintainPlanListReq 维护模板列表 +type AdminMaintainPlanListReq struct { + g.Meta `path:"/admin/service/maintain-plan/list" method:"get" tags:"admin" summary:"维护模板列表"` +} +type AdminMaintainPlanListRes struct { + List []AdminMaintainPlanItem `json:"list"` +} + +// AdminMaintainPlanSaveReq 保存维护模板 +type AdminMaintainPlanSaveReq struct { + g.Meta `path:"/admin/service/maintain-plan/save" method:"post" tags:"admin" summary:"保存维护模板"` + Id int64 `json:"id"` + Name string `v:"required#请填写模板名称" json:"name"` + FreeYears int `v:"min:0" json:"freeYears"` + YearlyFeeCents int64 `v:"min:0" json:"yearlyFeeCents"` + SourceOnly int `json:"sourceOnly"` + Sort int `json:"sort"` + IsEnabled int `json:"isEnabled"` + Remark string `v:"length:0,255" json:"remark"` +} +type AdminMaintainPlanSaveRes struct { + Id int64 `json:"id"` +} + +// AdminMaintainPlanDeleteReq 删除维护模板 +type AdminMaintainPlanDeleteReq struct { + g.Meta `path:"/admin/service/maintain-plan/delete" method:"post" tags:"admin" summary:"删除维护模板"` + Id int64 `v:"required" json:"id"` +} +type AdminMaintainPlanDeleteRes struct{} + +// ---------- 付款分期模板 ---------- + +// AdminPayPlanItem 付款分期模板 +type AdminPayPlanItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + Schedule string `json:"schedule"` // JSON:[{seq,percent,trigger}] + Sort int `json:"sort"` + IsEnabled int `json:"is_enabled"` + Remark string `json:"remark"` +} + +// AdminPayPlanListReq 分期模板列表 +type AdminPayPlanListReq struct { + g.Meta `path:"/admin/service/pay-plan/list" method:"get" tags:"admin" summary:"付款分期模板列表"` +} +type AdminPayPlanListRes struct { + List []AdminPayPlanItem `json:"list"` +} + +// AdminPayPlanSaveReq 保存分期模板 +type AdminPayPlanSaveReq struct { + g.Meta `path:"/admin/service/pay-plan/save" method:"post" tags:"admin" summary:"保存付款分期模板"` + Id int64 `json:"id"` + Name string `v:"required#请填写模板名称" json:"name"` + Schedule string `v:"required#请填写分期计划 JSON" json:"schedule"` + Sort int `json:"sort"` + IsEnabled int `json:"isEnabled"` + Remark string `v:"length:0,255" json:"remark"` +} +type AdminPayPlanSaveRes struct { + Id int64 `json:"id"` +} + +// AdminPayPlanDeleteReq 删除分期模板 +type AdminPayPlanDeleteReq struct { + g.Meta `path:"/admin/service/pay-plan/delete" method:"post" tags:"admin" summary:"删除付款分期模板"` + Id int64 `v:"required" json:"id"` +} +type AdminPayPlanDeleteRes struct{} + +// ---------- 分期到账 / 维护期 ---------- + +// AdminProjectPayMarkReq 标记分期到账(后台确认收款,用户端推站内信) +type AdminProjectPayMarkReq struct { + g.Meta `path:"/admin/service/project/pay/mark" method:"post" tags:"admin" summary:"标记分期到账"` + Id int64 `v:"required" json:"id"` + PaidNote string `v:"length:0,64#备注最多 64 字" json:"paidNote"` +} +type AdminProjectPayMarkRes struct{} + +// AdminProjectPayUnmarkReq 撤销分期到账(误操作回退) +type AdminProjectPayUnmarkReq struct { + g.Meta `path:"/admin/service/project/pay/unmark" method:"post" tags:"admin" summary:"撤销分期到账"` + Id int64 `v:"required" json:"id"` +} +type AdminProjectPayUnmarkRes struct{} + +// AdminProjectMaintainRenewReq 维护费 +1 年已收(paid_until 顺延一年并通知用户) +type AdminProjectMaintainRenewReq struct { + g.Meta `path:"/admin/service/project/maintain/renew" method:"post" tags:"admin" summary:"维护费续收一年"` + Id int64 `v:"required" json:"id"` +} +type AdminProjectMaintainRenewRes struct { + PaidUntil string `json:"paid_until"` +} + +// AdminProjectMaintainAdjustReq 调整维护期起算日(重算已缴至与到期提醒) +type AdminProjectMaintainAdjustReq struct { + g.Meta `path:"/admin/service/project/maintain/adjust" method:"post" tags:"admin" summary:"调整维护期起算日"` + Id int64 `v:"required" json:"id"` + StartAt string `v:"required#请选择起算日期" json:"startAt"` // YYYY-MM-DD +} +type AdminProjectMaintainAdjustRes struct { + PaidUntil string `json:"paid_until"` +} + +// ---------- 缴费提醒设置 ---------- + +// AdminMaintainRemindGetReq 提醒设置读取 +type AdminMaintainRemindGetReq struct { + g.Meta `path:"/admin/service/maintain-remind/get" method:"get" tags:"admin" summary:"维护费提醒设置读取"` +} +type AdminMaintainRemindGetRes struct { + RemindDays int `json:"remind_days"` + AdminNotifyUserId int64 `json:"admin_notify_user_id"` +} + +// AdminMaintainRemindSaveReq 提醒设置保存 +type AdminMaintainRemindSaveReq struct { + g.Meta `path:"/admin/service/maintain-remind/save" method:"post" tags:"admin" summary:"维护费提醒设置保存"` + RemindDays int `v:"min:1|max:90#提醒天数需在 1-90 之间" json:"remindDays"` + AdminNotifyUserId int64 `json:"adminNotifyUserId"` +} +type AdminMaintainRemindSaveRes struct{} diff --git a/api/admin/v1/service.go b/api/admin/v1/service.go new file mode 100644 index 0000000..ddb33a5 --- /dev/null +++ b/api/admin/v1/service.go @@ -0,0 +1,181 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 管理端软件开发服务二期契约(迭代-2026-09-30): +// 介绍页模块(blocks)/ 作品案例(works)/ 报价申请(quotes) +// +// 图片字段库内只存相对路径(/static/...),响应里 *Url 为按 site.base_url 拼接的 +// 绝对地址;保存时提交上传接口返回的 path。报价处理(handle)后向用户推站内信。 +// ============================================================================ + +// ---------- 介绍页模块 ---------- + +// AdminServiceBlockItem 模块项(响应 snake_case) +type AdminServiceBlockItem struct { + Id int64 `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ImagePath string `json:"image_path"` + ImageUrl string `json:"image_url"` + Images []string `json:"images"` // 相对路径数组(编辑回填用) + ImageUrls []string `json:"image_urls"` + Content string `json:"content"` + Sort int `json:"sort"` + IsEnabled int `json:"is_enabled"` + UpdatedAt string `json:"updated_at"` +} + +// AdminServiceBlockListReq 模块列表(含停用;管理端全部可见) +type AdminServiceBlockListReq struct { + g.Meta `path:"/admin/service/block/list" method:"get" tags:"admin" summary:"服务介绍页模块列表"` +} +type AdminServiceBlockListRes struct { + List []AdminServiceBlockItem `json:"list"` +} + +// AdminServiceBlockSaveReq 新增/编辑模块(id=0 新建)。images 传相对路径数组。 +type AdminServiceBlockSaveReq struct { + g.Meta `path:"/admin/service/block/save" method:"post" tags:"admin" summary:"保存服务介绍页模块"` + Id int64 `json:"id"` + Kind string `v:"required|in:hero,rich,image_text,image_list,flow,checklist,price,developer,works,goods#请选择模块类型|模块类型不合法" json:"kind"` + Title string `v:"length:0,128#标题最多 128 字" json:"title"` + Subtitle string `v:"length:0,255#副标题最多 255 字" json:"subtitle"` + ImagePath string `json:"imagePath"` + Images []string `json:"images"` + Content string `json:"content"` + Sort int `json:"sort"` + IsEnabled int `json:"isEnabled"` +} +type AdminServiceBlockSaveRes struct { + Id int64 `json:"id"` +} + +// AdminServiceBlockDeleteReq 删除模块 +type AdminServiceBlockDeleteReq struct { + g.Meta `path:"/admin/service/block/delete" method:"post" tags:"admin" summary:"删除服务介绍页模块"` + Id int64 `v:"required" json:"id"` +} +type AdminServiceBlockDeleteRes struct{} + +// ---------- 作品案例 ---------- + +// AdminServiceWorkItem 作品列表项(不含 content_md) +type AdminServiceWorkItem struct { + Id int64 `json:"id"` + Category int `json:"category"` + CategoryName string `json:"category_name"` + Title string `json:"title"` + Summary string `json:"summary"` + CoverPath string `json:"cover_path"` + CoverUrl string `json:"cover_url"` + PriceCents int64 `json:"price_cents"` + PriceNote string `json:"price_note"` + Sort int `json:"sort"` + IsEnabled int `json:"is_enabled"` + CreatedAt string `json:"created_at"` +} + +// AdminServiceWorkListReq 作品列表 +type AdminServiceWorkListReq struct { + g.Meta `path:"/admin/service/work/list" method:"get" tags:"admin" summary:"软件开发作品列表"` + Keyword string `json:"keyword"` + Category int `json:"category"` + IsEnabled *int `json:"isEnabled"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type AdminServiceWorkListRes struct { + List []AdminServiceWorkItem `json:"list"` + Total int `json:"total"` +} + +// AdminServiceWorkDetailReq 作品详情(编辑回填) +type AdminServiceWorkDetailReq struct { + g.Meta `path:"/admin/service/work/detail" method:"get" tags:"admin" summary:"软件开发作品详情"` + Id int64 `v:"required" json:"id"` +} +type AdminServiceWorkDetailRes struct { + Id int64 `json:"id"` + Category int `json:"category"` + Title string `json:"title"` + Summary string `json:"summary"` + ContentMd string `json:"content_md"` + CoverPath string `json:"cover_path"` + CoverUrl string `json:"cover_url"` + Images []string `json:"images"` + PriceCents int64 `json:"price_cents"` + PriceNote string `json:"price_note"` + Sort int `json:"sort"` + IsEnabled int `json:"is_enabled"` +} + +// AdminServiceWorkSaveReq 新增/编辑作品 +type AdminServiceWorkSaveReq struct { + g.Meta `path:"/admin/service/work/save" method:"post" tags:"admin" summary:"保存软件开发作品"` + Id int64 `json:"id"` + Category int `v:"required|in:1,2,3,4#请选择分类|分类不合法" json:"category"` + Title string `v:"required#请填写作品名称" json:"title"` + Summary string `v:"length:0,500#简介最多 500 字" json:"summary"` + ContentMd string `json:"contentMd"` + CoverPath string `json:"coverPath"` + Images []string `json:"images"` + PriceCents int64 `v:"min:0#价格不能为负" json:"priceCents"` + PriceNote string `v:"length:0,64#价格备注最多 64 字" json:"priceNote"` + Sort int `json:"sort"` + IsEnabled int `json:"isEnabled"` +} +type AdminServiceWorkSaveRes struct { + Id int64 `json:"id"` +} + +// AdminServiceWorkDeleteReq 删除作品(软删除) +type AdminServiceWorkDeleteReq struct { + g.Meta `path:"/admin/service/work/delete" method:"post" tags:"admin" summary:"删除软件开发作品"` + Id int64 `v:"required" json:"id"` +} +type AdminServiceWorkDeleteRes struct{} + +// ---------- 报价申请 ---------- + +// AdminServiceQuoteItem 报价申请列表项 +type AdminServiceQuoteItem struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + UserNickname string `json:"user_nickname"` + WorkId int64 `json:"work_id"` + WorkTitle string `json:"work_title"` + Contact string `json:"contact"` + Requirement string `json:"requirement"` + Budget string `json:"budget"` + Status int `json:"status"` + StatusText string `json:"status_text"` + QuoteCents int64 `json:"quote_cents"` + QuoteNote string `json:"quote_note"` + HandledAt string `json:"handled_at"` + CreatedAt string `json:"created_at"` +} + +// AdminServiceQuoteListReq 报价申请列表 +type AdminServiceQuoteListReq struct { + g.Meta `path:"/admin/service/quote/list" method:"get" tags:"admin" summary:"报价申请列表"` + Status *int `json:"status"` // nil=全部 + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type AdminServiceQuoteListRes struct { + List []AdminServiceQuoteItem `json:"list"` + Total int `json:"total"` +} + +// AdminServiceQuoteHandleReq 处理报价申请(填报价 + 状态),用户端推站内信 +type AdminServiceQuoteHandleReq struct { + g.Meta `path:"/admin/service/quote/handle" method:"post" tags:"admin" summary:"处理报价申请"` + Id int64 `v:"required" json:"id"` + Status int `v:"in:0,1,2,3#状态不合法" json:"status"` + QuoteCents int64 `v:"min:0#报价不能为负" json:"quoteCents"` + QuoteNote string `v:"length:0,500#报价说明最多 500 字" json:"quoteNote"` +} +type AdminServiceQuoteHandleRes struct{} diff --git a/api/admin/v1/setting.go b/api/admin/v1/setting.go new file mode 100644 index 0000000..3f1ca64 --- /dev/null +++ b/api/admin/v1/setting.go @@ -0,0 +1,33 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 管理端系统配置契约(迭代-2026-09-29) +// +// 走 settings 表(约定 S9:配置一律走 settings 表,不写 config.yaml);本模块 +// 只开放白名单键,当前是 site.base_url(图片等静态资源的对外拼接前缀)。 +// ============================================================================ + +// AdminSettingItem 配置项 +type AdminSettingItem struct { + Key string `json:"key"` + Value string `json:"value"` + Remark string `json:"remark"` +} + +// AdminSettingGetReq 读取白名单配置项 +type AdminSettingGetReq struct { + g.Meta `path:"/admin/setting/get" method:"get" tags:"admin" summary:"系统配置读取"` +} +type AdminSettingGetRes struct { + Items []AdminSettingItem `json:"items"` +} + +// AdminSettingSaveReq 保存一项配置(key 必须在白名单内) +type AdminSettingSaveReq struct { + g.Meta `path:"/admin/setting/save" method:"post" tags:"admin" summary:"系统配置保存"` + Key string `v:"required" json:"key"` + Value string `v:"length:0,255#配置值最多 255 字" json:"value"` +} +type AdminSettingSaveRes struct{} diff --git a/api/user/v1/app_config.go b/api/user/v1/app_config.go new file mode 100644 index 0000000..664e3e3 --- /dev/null +++ b/api/user/v1/app_config.go @@ -0,0 +1,19 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// C 端应用配置契约(迭代-2026-09-30) +// +// 首页等界面级配置的轻量下发口。值来自 settings 表,key 对齐小程序端 +// `config/features.ts` 的 FEATURE_ENTRIES key;未配置的 key 不出现在 map 中。 +// ============================================================================ + +// AppConfigReq 应用配置(首页功能卡背景图等) +type AppConfigReq struct { + g.Meta `path:"/app/config" method:"get" tags:"app" summary:"应用配置"` +} +type AppConfigRes struct { + // FeatureCovers 首页便捷功能卡背景图:featureKey → 绝对 URL(后端按 site.base_url 拼好) + FeatureCovers map[string]string `json:"feature_covers"` +} diff --git a/api/user/v1/goods.go b/api/user/v1/goods.go new file mode 100644 index 0000000..5acbdcc --- /dev/null +++ b/api/user/v1/goods.go @@ -0,0 +1,52 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// C 端软件开发服务契约(迭代-2026-09-29):list / detail +// +// 仅上架(is_enabled=1)商品可见;封面 url 由后端按 site.base_url 拼成绝对地址, +// 小程序端拿到即可直接用于 ,无需自行拼接域名。 +// ============================================================================ + +// GoodsItem 服务列表项(响应 snake_case) +type GoodsItem struct { + Id int64 `json:"id"` + Category int `json:"category"` // 1网站 2小程序 3App 4桌面 + CategoryName string `json:"category_name"` // 分类展示名(服务端映射) + Title string `json:"title"` + Summary string `json:"summary"` + CoverUrl string `json:"cover_url"` // 封面绝对 URL(可能为空 = 无封面) + PriceCents int64 `json:"price_cents"` // 0 = 面议 + PriceNote string `json:"price_note"` // 价格备注(如「起」「/项目」) +} + +// GoodsListReq 服务列表(C 端,仅上架) +type GoodsListReq struct { + g.Meta `path:"/goods/list" method:"get" tags:"goods" summary:"软件开发服务列表"` + Category int `json:"category"` // 0=全部 + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type GoodsListRes struct { + List []GoodsItem `json:"list"` + Total int `json:"total"` +} + +// GoodsDetailReq 服务详情(C 端,仅上架;含 Markdown 原文,由前端解析渲染) +type GoodsDetailReq struct { + g.Meta `path:"/goods/detail" method:"get" tags:"goods" summary:"软件开发服务详情"` + Id int64 `v:"required" json:"id"` +} +type GoodsDetailRes struct { + Id int64 `json:"id"` + Category int `json:"category"` + CategoryName string `json:"category_name"` + Title string `json:"title"` + Summary string `json:"summary"` + ContentMd string `json:"content_md"` + CoverUrl string `json:"cover_url"` + PriceCents int64 `json:"price_cents"` + PriceNote string `json:"price_note"` + UpdatedAt string `json:"updated_at"` +} diff --git a/api/user/v1/member.go b/api/user/v1/member.go index e3899cf..675af14 100644 --- a/api/user/v1/member.go +++ b/api/user/v1/member.go @@ -93,6 +93,7 @@ type MemberOrderCheckRes struct { type MemberOrdersReq struct { g.Meta `path:"/member/orders" method:"get" tags:"member" summary:"我的会员订单"` Limit int `json:"limit"` + Tab int `json:"tab"` // 0全部 1待付款 2已生效 3退款 } type MemberOrderOut struct { OutTradeNo string `json:"out_trade_no"` diff --git a/api/user/v1/message.go b/api/user/v1/message.go new file mode 100644 index 0000000..e3ca608 --- /dev/null +++ b/api/user/v1/message.go @@ -0,0 +1,47 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// C 端站内信契约(迭代-2026-09-30) +// +// 写入点(后端业务事件统一落 user_messages,见 logic/message.go PushMessage): +// 会员开通/次数包到账(发货成功)、报价申请回执、报价处理结果、反馈官方回复。 +// C 端只读:列表 / 未读数 / 标记已读(单条或全部)。 +// ============================================================================ + +// MessageItem 站内信条目 +type MessageItem struct { + Id int64 `json:"id"` + Type string `json:"type"` // order_delivered/quote_submitted/quote_handled/feedback_replied/system + Title string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` // 小程序内跳转路由,空=不跳转 + IsRead int `json:"is_read"` + CreatedAt string `json:"created_at"` +} + +// MessageListReq 我的消息(最近 N 条 + 未读数;不自动置已读,已读走 /message/read) +type MessageListReq struct { + g.Meta `path:"/message/list" method:"get" tags:"message" summary:"我的站内信"` + Limit int `json:"limit"` +} +type MessageListRes struct { + List []MessageItem `json:"list"` + Unread int `json:"unread"` +} + +// MessageUnreadReq 未读消息数(首页 / 我的页徽标) +type MessageUnreadReq struct { + g.Meta `path:"/message/unread" method:"get" tags:"message" summary:"未读站内信数"` +} +type MessageUnreadRes struct { + Count int `json:"count"` +} + +// MessageReadReq 标记已读:Id>0 单条;Id=0 全部 +type MessageReadReq struct { + g.Meta `path:"/message/read" method:"post" tags:"message" summary:"站内信标记已读"` + Id int64 `json:"id"` +} +type MessageReadRes struct{} diff --git a/api/user/v1/project.go b/api/user/v1/project.go new file mode 100644 index 0000000..fa0020e --- /dev/null +++ b/api/user/v1/project.go @@ -0,0 +1,115 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// C 端开发项目契约(迭代-2026-09-30 三期) +// +// 流程:后台建单(生成绑定令牌)→ 用户扫小程序码进入绑定页 → POST bind 绑定到 +// 当前用户 → 后台维护阶段/小节点/交付物(原型、UI 设计稿等)→ 用户端只读查看。 +// ============================================================================ + +// ProjectStageItem 项目阶段(含小节点与交付物) +type ProjectStageItem struct { + Id int64 `json:"id"` + Title string `json:"title"` + Status int `json:"status"` // 0未开始 1进行中 2已完成 + StatusText string `json:"status_text"` + Nodes []ProjectNodeItem `json:"nodes"` + Files []ProjectFileItem `json:"files"` +} + +// ProjectNodeItem 阶段小节点 +type ProjectNodeItem struct { + Id int64 `json:"id"` + Title string `json:"title"` + IsDone int `json:"is_done"` + DoneAt string `json:"done_at"` +} + +// ProjectFileItem 阶段交付物(图片,绝对 URL) +type ProjectFileItem struct { + Id int64 `json:"id"` + FileName string `json:"file_name"` + FileUrl string `json:"file_url"` +} + +// ProjectPayItem 项目分期(后台确认到账,用户只读) +type ProjectPayItem struct { + Id int64 `json:"id"` + Seq int `json:"seq"` + Percent int `json:"percent"` + AmountCents int64 `json:"amount_cents"` + Trigger string `json:"trigger"` + TriggerText string `json:"trigger_text"` + Status int `json:"status"` // 0待支付 1已支付 + PaidAt string `json:"paid_at"` +} + +// ProjectMaintainOut 维护期信息 +type ProjectMaintainOut struct { + PlanName string `json:"plan_name"` + FreeYears int `json:"free_years"` + YearlyFeeCents int64 `json:"yearly_fee_cents"` + SourceOnly int `json:"source_only"` // 1=仅源码无售后 + Accepted int `json:"accepted"` + AcceptedAt string `json:"accepted_at"` + StartAt string `json:"start_at"` // 维护期起算日(后台可调) + PaidUntil string `json:"paid_until"` // 维护费已缴至(= 下次缴费日) +} + +// ProjectItem 项目列表项 +type ProjectItem struct { + Id int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + PriceCents int64 `json:"price_cents"` + PriceNote string `json:"price_note"` + DueNote string `json:"due_note"` + Status int `json:"status"` + StatusText string `json:"status_text"` + StageTotal int `json:"stage_total"` // 已完成阶段数(输出时算好,前端直接展示进度) + StageDone int `json:"stage_done"` + CoverUrl string `json:"cover_url"` // 入口图(第一个阶段交付物图片,可能为空) + UpdatedAt string `json:"updated_at"` +} + +// ProjectMineReq 我的项目(扫码绑定后可见) +type ProjectMineReq struct { + g.Meta `path:"/service/project/mine" method:"get" tags:"service" summary:"我的开发项目"` +} +type ProjectMineRes struct { + List []ProjectItem `json:"list"` +} + +// ProjectDetailReq 项目详情(仅限已绑定用户;含阶段/节点/交付物/分期/维护期) +type ProjectDetailReq struct { + g.Meta `path:"/service/project/detail" method:"get" tags:"service" summary:"开发项目详情"` + Id int64 `v:"required" json:"id"` +} +type ProjectDetailRes struct { + ProjectItem + Stages []ProjectStageItem `json:"stages"` + Pays []ProjectPayItem `json:"pays"` // 分期付款(进度与到账状态) + Maintain *ProjectMaintainOut `json:"maintain"` // 维护期信息(未选模板为 null) + CanAccept int `json:"can_accept"` // 1=可点「确认验收」(已交付且未验收) +} + +// ProjectAcceptReq 用户确认验收:进入维护期(维护期起点=确认当天,后台可调整)。 +type ProjectAcceptReq struct { + g.Meta `path:"/service/project/accept" method:"post" tags:"service" summary:"确认验收进入维护期"` + Id int64 `v:"required" json:"id"` +} +type ProjectAcceptRes struct { + MaintainStartAt string `json:"maintain_start_at"` + PaidUntil string `json:"paid_until"` +} + +// ProjectBindReq 扫码绑定项目(令牌来自小程序码 scene) +type ProjectBindReq struct { + g.Meta `path:"/service/project/bind" method:"post" tags:"service" summary:"绑定开发项目"` + Token string `v:"required#缺少绑定令牌" json:"token"` +} +type ProjectBindRes struct { + ProjectId int64 `json:"project_id"` +} diff --git a/api/user/v1/service.go b/api/user/v1/service.go new file mode 100644 index 0000000..a34c41a --- /dev/null +++ b/api/user/v1/service.go @@ -0,0 +1,102 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// C 端软件开发服务二期契约(迭代-2026-09-30):介绍页模块 / 作品案例 / 报价申请 +// +// - 介绍页模块(service_blocks)由管理端逐块编辑(预设排版布局),C 端按 sort 输出; +// - 图片字段库内只存相对路径,输出统一为绝对 URL(同 goods 封面约定); +// - 报价申请提交后写站内信回执;处理结果(已报价/成交/关闭)再推站内信。 +// ============================================================================ + +// ServiceBlockOut 介绍页模块(响应 snake_case) +type ServiceBlockOut struct { + Id int64 `json:"id"` + Kind string `json:"kind"` // hero/rich/image_text/image_list/developer/works/goods + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ImageUrl string `json:"image_url"` // 主图绝对 URL(可能为空) + Images []string `json:"images"` // 图集绝对 URL(image_list) + Content string `json:"content"` // Markdown 原文,前端解析渲染 +} + +// ServicePageReq 服务介绍页模块列表(仅启用,sort 升序) +type ServicePageReq struct { + g.Meta `path:"/service/page" method:"get" tags:"service" summary:"软件开发服务介绍页"` +} +type ServicePageRes struct { + List []ServiceBlockOut `json:"list"` +} + +// WorkItem 作品列表项 +type WorkItem struct { + Id int64 `json:"id"` + Category int `json:"category"` + CategoryName string `json:"category_name"` + Title string `json:"title"` + Summary string `json:"summary"` + CoverUrl string `json:"cover_url"` + PriceCents int64 `json:"price_cents"` // 0 = 面议 + PriceNote string `json:"price_note"` +} + +// WorksListReq 作品列表(仅上架) +type WorksListReq struct { + g.Meta `path:"/service/works" method:"get" tags:"service" summary:"软件开发作品列表"` + Category int `json:"category"` // 0=全部 + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type WorksListRes struct { + List []WorkItem `json:"list"` + Total int `json:"total"` +} + +// WorkDetailReq 作品详情(含图集与 Markdown 详情) +type WorkDetailReq struct { + g.Meta `path:"/service/works/detail" method:"get" tags:"service" summary:"软件开发作品详情"` + Id int64 `v:"required" json:"id"` +} +type WorkDetailRes struct { + WorkItem + Images []string `json:"images"` // 详情图集(绝对 URL) + ContentMd string `json:"content_md"` + UpdatedAt string `json:"updated_at"` +} + +// QuoteSubmitReq 提交报价申请(需求 → 站内信回执) +type QuoteSubmitReq struct { + g.Meta `path:"/service/quote/submit" method:"post" tags:"service" summary:"提交软件开发报价申请"` + WorkId int64 `json:"work_id"` // 0 = 通用申请(不关联作品) + Contact string `v:"required#请填写联系方式" json:"contact"` + Requirement string `v:"required#请描述您的需求" json:"requirement"` + Budget string `json:"budget"` // 预算范围文案,可空 +} +type QuoteSubmitRes struct { + Id int64 `json:"id"` +} + +// QuoteMineItem 我的报价申请条目(含处理结果) +type QuoteMineItem struct { + Id int64 `json:"id"` + WorkId int64 `json:"work_id"` + WorkTitle string `json:"work_title"` + Requirement string `json:"requirement"` + Budget string `json:"budget"` + Status int `json:"status"` + StatusText string `json:"status_text"` + QuoteCents int64 `json:"quote_cents"` // 0=面议/未填 + QuoteNote string `json:"quote_note"` + HandledAt string `json:"handled_at"` + CreatedAt string `json:"created_at"` +} + +// QuoteMineReq 我的报价申请(最近 N 条) +type QuoteMineReq struct { + g.Meta `path:"/service/quote/mine" method:"get" tags:"service" summary:"我的报价申请"` + Limit int `json:"limit"` +} +type QuoteMineRes struct { + List []QuoteMineItem `json:"list"` +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index ef175c1..cd99d0c 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -27,6 +27,7 @@ const ( cronPatternReleaseCoupons = "0 */5 * * * *" // 免单券超时占用释放:每 5 分钟 cronPatternCloseOrders = "0 */5 * * * *" // 订单超时关闭:每 5 分钟 cronPatternRefundPoll = "0 */2 * * * *" // 退款结果轮询:每 2 分钟(logic/order_refund.go) + cronPatternMaintainRemind = "0 40 9 * * *" // 维护费到期提醒:每天 09:40(logic/project_flow.go) ) var ( @@ -87,6 +88,15 @@ var ( g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "refund-status-poll", cronPatternRefundPoll) } + // 维护费到期提醒:已验收项目的年度维护费,到期前 N 天开始提醒(隔 3 天一发、最后 3 天天天发) + if _, err = gcron.AddSingleton(ctx, cronPatternMaintainRemind, func(c context.Context) { + logic.ScanMaintainReminders(c) + }, "maintain-remind-scan"); err != nil { + g.Log().Warningf(ctx, "[cron] 维护费提醒任务注册失败: %v", err) + } else { + g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "maintain-remind-scan", cronPatternMaintainRemind) + } + s := g.Server() // 公开接口:小程序登录、开发登录、管理端登录、虚拟支付发货推送回调 s.Group("/", func(group *ghttp.RouterGroup) { @@ -99,12 +109,15 @@ var ( s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(logic.CORS, logic.Response, logic.UserAuth) group.Bind(controller.UserAuth, controller.Member, controller.Quota, controller.Note, - controller.Promo, controller.Coupon) + controller.Promo, controller.Coupon, controller.Goods, controller.AppConfig, + controller.Service, controller.ServiceProject) }) // 管理端接口:鉴权(AdminAuth)+ 权限分级(AdminPerm,按角色权限点放行) s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(logic.CORS, logic.Response, logic.AdminAuth, logic.AdminPerm) - group.Bind(controller.AdminAuth, controller.Event, controller.AdminPromo, controller.AdminCoupon, controller.AdminOrder) + group.Bind(controller.AdminAuth, controller.Event, controller.AdminPromo, controller.AdminCoupon, controller.AdminOrder, + controller.AdminGoods, controller.AdminAttachment, controller.AdminSetting, controller.AdminService, + controller.AdminServiceProject) }) // 活动小程序码静态资源(生成时落在 manifest/qrcode,供管理端/前端访问) // ⚠️ AddStaticPath 在目录不存在时**直接 FATAL 退出、不降级**,而该目录原先只在 diff --git a/internal/consts/consts.go b/internal/consts/consts.go index d09c8b5..bd3e022 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -338,3 +338,178 @@ const ( RefundStatusSuccess = 2 // 退款成功(含手工退款登记) RefundStatusFailed = 3 // 退款失败(可重新发起) ) + +// ============================================================================ +// 软件开发服务域(迭代-2026-09-29) +// ============================================================================ + +// 表名(服务商品 / 上传附件登记 / 服务介绍页模块 / 作品案例 / 报价申请 / 站内信 / 开发项目) +const ( + TableGoods = "goods" + TableAttachments = "attachments" + TableServiceBlocks = "service_blocks" + TableServiceWorks = "service_works" + TableServiceQuotes = "service_quotes" + TableUserMessages = "user_messages" + TableServiceProjects = "service_projects" + TableProjectStages = "service_project_stages" + TableProjectNodes = "service_project_nodes" + TableProjectFiles = "service_project_files" +) + +// 服务介绍页模块类型(service_blocks.kind,预设排版布局) +const ( + ServiceBlockHero = "hero" // 头图横幅:大标题(支持 **高亮**)+ 标语 + ServiceBlockRich = "rich" // 富文本:Markdown 图文混排 + ServiceBlockImageText = "image_text" // 图文:图 + 说明文字 + ServiceBlockImageList = "image_list" // 图片列表:多图网格(点击预览) + ServiceBlockFlow = "flow" // 服务流程:时间轴,每行「标题|描述」 + ServiceBlockChecklist = "checklist" // 交付清单:勾选卡片,每行一项 + ServiceBlockPrice = "price" // 价格卡:每行「名称|描述|价格」 + ServiceBlockDeveloper = "developer" // 开发者简介:头像 + 名字 + 介绍 + ServiceBlockWorks = "works" // 作品模块入口(进作品列表) + ServiceBlockGoods = "goods" // 服务与价格清单入口(进服务列表) +) + +// 报价申请状态(service_quotes.status) +const ( + QuoteStatusPending = 0 // 待处理 + QuoteStatusQuoted = 1 // 已报价 + QuoteStatusDone = 2 // 已成交 + QuoteStatusClosed = 3 // 已关闭 +) + +// QuoteStatusNameOf 报价状态 → 展示名 +func QuoteStatusNameOf(s int) string { + switch s { + case QuoteStatusPending: + return "待处理" + case QuoteStatusQuoted: + return "已报价" + case QuoteStatusDone: + return "已成交" + case QuoteStatusClosed: + return "已关闭" + } + return "未知" +} + +// 站内信类型(user_messages.type) +const ( + MsgTypeOrderDelivered = "order_delivered" // 会员开通 / 次数包到账(订单生效) + MsgTypeQuoteSubmitted = "quote_submitted" // 报价申请已提交(回执) + MsgTypeQuoteHandled = "quote_handled" // 报价处理结果(已报价/成交/关闭) + MsgTypeFeedbackReplied = "feedback_replied" // 反馈收到官方回复 + MsgTypeProjectUpdate = "project_update" // 开发项目进度更新(阶段/交付物/绑定/交付) + MsgTypeSystem = "system" // 系统通知 +) + +// 开发项目状态(service_projects.status) +const ( + ProjectStatusRunning = 1 // 进行中 + ProjectStatusDone = 2 // 已交付 + ProjectStatusClosed = 3 // 已关闭 +) + +// ProjectStatusNameOf 项目状态 → 展示名 +func ProjectStatusNameOf(s int) string { + switch s { + case ProjectStatusRunning: + return "进行中" + case ProjectStatusDone: + return "已交付" + case ProjectStatusClosed: + return "已关闭" + } + return "未知" +} + +// 项目阶段状态(service_project_stages.status) +const ( + StageStatusPending = 0 // 未开始 + StageStatusDoing = 1 // 进行中 + StageStatusDone = 2 // 已完成 +) + +// 项目分期触发节点(service_project_pays.trigger) +const ( + PayTriggerStart = "start" // 开工前(定金) + PayTriggerMidDemo = "mid_demo" // 中期演示确认后 + PayTriggerOnlineBefore = "online_before" // 上线前(尾款) + PayTriggerOnlineAfter = "online_after" // 上线后(尾款) +) + +// PayTriggerNameOf 分期触发节点 → 展示名 +func PayTriggerNameOf(t string) string { + switch t { + case PayTriggerStart: + return "开工前(定金)" + case PayTriggerMidDemo: + return "中期演示确认后" + case PayTriggerOnlineBefore: + return "上线前" + case PayTriggerOnlineAfter: + return "上线后" + } + return t +} + +// 项目分期状态(service_project_pays.status) +const ( + PayStatusPending = 0 // 待支付 + PayStatusPaid = 1 // 已支付 +) + +// 表名追加(开发项目四期:维护模板 / 付款分期模板 / 项目分期) +const ( + TableMaintainPlans = "service_maintain_plans" + TablePayPlans = "service_pay_plans" + TableProjectPays = "service_project_pays" +) + +// StageStatusNameOf 阶段状态 → 展示名 +func StageStatusNameOf(s int) string { + switch s { + case StageStatusDoing: + return "进行中" + case StageStatusDone: + return "已完成" + } + return "未开始" +} + +// 软件开发服务分类(goods.category) +const ( + GoodsCategoryWebsite = 1 // 网站开发系列 + GoodsCategoryMiniProgram = 2 // 小程序开发系列 + GoodsCategoryApp = 3 // App 开发系列 + GoodsCategoryDesktop = 4 // 桌面程序开发系列 +) + +// GoodsCategoryNameOf 分类值 → 展示名(未知值回落「其他」) +func GoodsCategoryNameOf(cat int) string { + switch cat { + case GoodsCategoryWebsite: + return "网站开发" + case GoodsCategoryMiniProgram: + return "小程序开发" + case GoodsCategoryApp: + return "App 开发" + case GoodsCategoryDesktop: + return "桌面程序开发" + } + return "其他" +} + +// 附件上传场景(attachments.scene) +const ( + AttachmentSceneAvatar = "avatar" // 用户头像(小程序端上传) + AttachmentSceneCover = "cover" // 服务封面(管理端上传) + AttachmentSceneAttachment = "attachment" // 通用附件(管理端上传) +) + +// 站点 settings 键 +const ( + SettingSiteBaseUrl = "site.base_url" // 图片等静态资源的对外拼接前缀;留空 = 按请求 Host 动态拼接 + SettingHomeFeatureCovers = "home.feature.covers" // 首页便捷功能卡背景图(JSON:featureKey → 相对路径),经 /app/config 下发 +) diff --git a/internal/controller/app_config.go b/internal/controller/app_config.go new file mode 100644 index 0000000..9abaddb --- /dev/null +++ b/internal/controller/app_config.go @@ -0,0 +1,22 @@ +package controller + +import ( + "context" + + userv1 "tool-api/api/user/v1" + "tool-api/internal/logic" +) + +// ============================================================================ +// 应用配置控制器(迭代-2026-09-30):C 端界面级配置下发 +// ============================================================================ + +// cAppConfig 用户端应用配置接口(需用户登录,挂 UserAuth 组) +type cAppConfig struct{} + +// AppConfig 用户端应用配置对象 +var AppConfig = &cAppConfig{} + +func (c *cAppConfig) AppConfig(ctx context.Context, req *userv1.AppConfigReq) (res *userv1.AppConfigRes, err error) { + return logic.AppConfig(ctx) +} diff --git a/internal/controller/attachment.go b/internal/controller/attachment.go new file mode 100644 index 0000000..5b35701 --- /dev/null +++ b/internal/controller/attachment.go @@ -0,0 +1,32 @@ +package controller + +import ( + "context" + + adminv1 "tool-api/api/admin/v1" + "tool-api/internal/logic" +) + +// ============================================================================ +// 附件管理控制器(迭代-2026-09-29):管理端上传 / 列表 / 删除 +// ============================================================================ + +// cAdminAttachment 管理端附件接口(需管理员登录) +type cAdminAttachment struct{} + +// AdminAttachment 管理端附件对象(挂 AdminAuth 组) +var AdminAttachment = &cAdminAttachment{} + +// Upload 管理端上传(multipart):scene=cover|attachment,文件字段名 file。 +// GoFrame 已把 multipart 表单绑定进 req(含 scene 字段校验 in:cover,attachment)。 +func (c *cAdminAttachment) Upload(ctx context.Context, req *adminv1.AdminUploadReq) (res *adminv1.AdminUploadRes, err error) { + return logic.AdminUploadFromRequest(ctx, req.Scene) +} + +func (c *cAdminAttachment) AttachmentList(ctx context.Context, req *adminv1.AdminAttachmentListReq) (res *adminv1.AdminAttachmentListRes, err error) { + return logic.AdminAttachmentList(ctx, req) +} + +func (c *cAdminAttachment) AttachmentDelete(ctx context.Context, req *adminv1.AdminAttachmentDeleteReq) (res *adminv1.AdminAttachmentDeleteRes, err error) { + return logic.AdminAttachmentDelete(ctx, req.Id) +} diff --git a/internal/controller/goods.go b/internal/controller/goods.go new file mode 100644 index 0000000..91ab2e7 --- /dev/null +++ b/internal/controller/goods.go @@ -0,0 +1,53 @@ +package controller + +import ( + "context" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/logic" +) + +// ============================================================================ +// 软件开发服务控制器(迭代-2026-09-29) +// +// 拆成两个对象分别挂到 UserAuth / AdminAuth 两组(与优惠码同款拆分, +// ⚠️ 同一 controller 对象不能同时 Bind 到两组,见 controller/promo.go 的说明): +// - Goods 用户端(list / detail,需用户登录) +// - AdminGoods 管理端(list / detail / save / delete) +// ============================================================================ + +// cGoods 用户端服务接口(需用户登录) +type cGoods struct{} + +// cAdminGoods 管理端服务接口(需管理员登录) +type cAdminGoods struct{} + +var ( + Goods = &cGoods{} + AdminGoods = &cAdminGoods{} +) + +func (c *cGoods) GoodsList(ctx context.Context, req *userv1.GoodsListReq) (res *userv1.GoodsListRes, err error) { + return logic.GoodsList(ctx, req) +} + +func (c *cGoods) GoodsDetail(ctx context.Context, req *userv1.GoodsDetailReq) (res *userv1.GoodsDetailRes, err error) { + return logic.GoodsDetail(ctx, req.Id) +} + +func (c *cAdminGoods) GoodsList(ctx context.Context, req *adminv1.AdminGoodsListReq) (res *adminv1.AdminGoodsListRes, err error) { + return logic.AdminGoodsList(ctx, req) +} + +func (c *cAdminGoods) GoodsDetail(ctx context.Context, req *adminv1.AdminGoodsDetailReq) (res *adminv1.AdminGoodsDetailRes, err error) { + return logic.AdminGoodsDetail(ctx, req.Id) +} + +func (c *cAdminGoods) GoodsSave(ctx context.Context, req *adminv1.AdminGoodsSaveReq) (res *adminv1.AdminGoodsSaveRes, err error) { + return logic.AdminGoodsSave(ctx, req) +} + +func (c *cAdminGoods) GoodsDelete(ctx context.Context, req *adminv1.AdminGoodsDeleteReq) (res *adminv1.AdminGoodsDeleteRes, err error) { + return logic.AdminGoodsDelete(ctx, req.Id) +} diff --git a/internal/controller/member.go b/internal/controller/member.go index cce73db..6bf0d3a 100644 --- a/internal/controller/member.go +++ b/internal/controller/member.go @@ -43,7 +43,7 @@ func (c *cMember) MemberOrderCheck(ctx context.Context, req *v1.MemberOrderCheck } func (c *cMember) MemberOrders(ctx context.Context, req *v1.MemberOrdersReq) (res *v1.MemberOrdersRes, err error) { - return logic.MemberOrders(ctx, req.Limit) + return logic.MemberOrders(ctx, req.Limit, req.Tab) } // PayNotify 虚拟支付「发货推送」回调(平台调用,无 token,需在 MP 后台配置该 URL)。 diff --git a/internal/controller/project.go b/internal/controller/project.go new file mode 100644 index 0000000..d59fa10 --- /dev/null +++ b/internal/controller/project.go @@ -0,0 +1,137 @@ +package controller + +import ( + "context" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/logic" +) + +// ============================================================================ +// 开发项目控制器(迭代-2026-09-30 三期) +// +// - ServiceProject 用户端(绑定 / 我的项目 / 详情) +// - AdminServiceProject 管理端(建单 / 码图 / 阶段 / 节点 / 交付物) +// ============================================================================ + +type cServiceProject struct{} + +type cAdminServiceProject struct{} + +var ( + ServiceProject = &cServiceProject{} + AdminServiceProject = &cAdminServiceProject{} +) + +// ---- 用户端 ---- + +func (c *cServiceProject) ProjectMine(ctx context.Context, req *userv1.ProjectMineReq) (res *userv1.ProjectMineRes, err error) { + return logic.ProjectMine(ctx) +} + +func (c *cServiceProject) ProjectDetail(ctx context.Context, req *userv1.ProjectDetailReq) (res *userv1.ProjectDetailRes, err error) { + return logic.ProjectDetail(ctx, req.Id) +} + +func (c *cServiceProject) ProjectBind(ctx context.Context, req *userv1.ProjectBindReq) (res *userv1.ProjectBindRes, err error) { + return logic.ProjectBind(ctx, req.Token) +} + +func (c *cServiceProject) ProjectAccept(ctx context.Context, req *userv1.ProjectAcceptReq) (res *userv1.ProjectAcceptRes, err error) { + return logic.ProjectAccept(ctx, req.Id) +} + +// ---- 管理端 ---- + +func (c *cAdminServiceProject) ProjectList(ctx context.Context, req *adminv1.AdminProjectListReq) (res *adminv1.AdminProjectListRes, err error) { + return logic.AdminProjectList(ctx, req) +} + +func (c *cAdminServiceProject) ProjectSave(ctx context.Context, req *adminv1.AdminProjectSaveReq) (res *adminv1.AdminProjectSaveRes, err error) { + return logic.AdminProjectSave(ctx, req) +} + +func (c *cAdminServiceProject) ProjectDelete(ctx context.Context, req *adminv1.AdminProjectDeleteReq) (res *adminv1.AdminProjectDeleteRes, err error) { + return logic.AdminProjectDelete(ctx, req.Id) +} + +func (c *cAdminServiceProject) ProjectDetail(ctx context.Context, req *adminv1.AdminProjectDetailReq) (res *adminv1.AdminProjectDetailRes, err error) { + return logic.AdminProjectDetail(ctx, req.Id) +} + +func (c *cAdminServiceProject) ProjectQrcode(ctx context.Context, req *adminv1.AdminProjectQrcodeReq) (res *adminv1.AdminProjectQrcodeRes, err error) { + return logic.AdminProjectQrcode(ctx, req.Id) +} + +func (c *cAdminServiceProject) ProjectStageSave(ctx context.Context, req *adminv1.AdminProjectStageSaveReq) (res *adminv1.AdminProjectStageSaveRes, err error) { + return logic.AdminProjectStageSave(ctx, req) +} + +func (c *cAdminServiceProject) ProjectStageDelete(ctx context.Context, req *adminv1.AdminProjectStageDeleteReq) (res *adminv1.AdminProjectStageDeleteRes, err error) { + return logic.AdminProjectStageDelete(ctx, req.Id) +} + +func (c *cAdminServiceProject) ProjectNodeSave(ctx context.Context, req *adminv1.AdminProjectNodeSaveReq) (res *adminv1.AdminProjectNodeSaveRes, err error) { + return logic.AdminProjectNodeSave(ctx, req) +} + +func (c *cAdminServiceProject) ProjectNodeDelete(ctx context.Context, req *adminv1.AdminProjectNodeDeleteReq) (res *adminv1.AdminProjectNodeDeleteRes, err error) { + return logic.AdminProjectNodeDelete(ctx, req.Id) +} + +func (c *cAdminServiceProject) ProjectFileAdd(ctx context.Context, req *adminv1.AdminProjectFileAddReq) (res *adminv1.AdminProjectFileAddRes, err error) { + return logic.AdminProjectFileAdd(ctx, req) +} + +func (c *cAdminServiceProject) ProjectFileDelete(ctx context.Context, req *adminv1.AdminProjectFileDeleteReq) (res *adminv1.AdminProjectFileDeleteRes, err error) { + return logic.AdminProjectFileDelete(ctx, req.Id) +} + +func (c *cAdminServiceProject) MaintainPlanList(ctx context.Context, req *adminv1.AdminMaintainPlanListReq) (res *adminv1.AdminMaintainPlanListRes, err error) { + return logic.AdminMaintainPlanList(ctx) +} + +func (c *cAdminServiceProject) MaintainPlanSave(ctx context.Context, req *adminv1.AdminMaintainPlanSaveReq) (res *adminv1.AdminMaintainPlanSaveRes, err error) { + return logic.AdminMaintainPlanSave(ctx, req) +} + +func (c *cAdminServiceProject) MaintainPlanDelete(ctx context.Context, req *adminv1.AdminMaintainPlanDeleteReq) (res *adminv1.AdminMaintainPlanDeleteRes, err error) { + return logic.AdminMaintainPlanDelete(ctx, req.Id) +} + +func (c *cAdminServiceProject) PayPlanList(ctx context.Context, req *adminv1.AdminPayPlanListReq) (res *adminv1.AdminPayPlanListRes, err error) { + return logic.AdminPayPlanList(ctx) +} + +func (c *cAdminServiceProject) PayPlanSave(ctx context.Context, req *adminv1.AdminPayPlanSaveReq) (res *adminv1.AdminPayPlanSaveRes, err error) { + return logic.AdminPayPlanSave(ctx, req) +} + +func (c *cAdminServiceProject) PayPlanDelete(ctx context.Context, req *adminv1.AdminPayPlanDeleteReq) (res *adminv1.AdminPayPlanDeleteRes, err error) { + return logic.AdminPayPlanDelete(ctx, req.Id) +} + +func (c *cAdminServiceProject) ProjectPayMark(ctx context.Context, req *adminv1.AdminProjectPayMarkReq) (res *adminv1.AdminProjectPayMarkRes, err error) { + return logic.AdminProjectPayMark(ctx, req) +} + +func (c *cAdminServiceProject) ProjectPayUnmark(ctx context.Context, req *adminv1.AdminProjectPayUnmarkReq) (res *adminv1.AdminProjectPayUnmarkRes, err error) { + return logic.AdminProjectPayUnmark(ctx, req.Id) +} + +func (c *cAdminServiceProject) ProjectMaintainRenew(ctx context.Context, req *adminv1.AdminProjectMaintainRenewReq) (res *adminv1.AdminProjectMaintainRenewRes, err error) { + return logic.AdminProjectMaintainRenew(ctx, req.Id) +} + +func (c *cAdminServiceProject) ProjectMaintainAdjust(ctx context.Context, req *adminv1.AdminProjectMaintainAdjustReq) (res *adminv1.AdminProjectMaintainAdjustRes, err error) { + return logic.AdminProjectMaintainAdjust(ctx, req) +} + +func (c *cAdminServiceProject) MaintainRemindGet(ctx context.Context, req *adminv1.AdminMaintainRemindGetReq) (res *adminv1.AdminMaintainRemindGetRes, err error) { + return logic.AdminMaintainRemindGet(ctx) +} + +func (c *cAdminServiceProject) MaintainRemindSave(ctx context.Context, req *adminv1.AdminMaintainRemindSaveReq) (res *adminv1.AdminMaintainRemindSaveRes, err error) { + return logic.AdminMaintainRemindSave(ctx, req) +} diff --git a/internal/controller/service.go b/internal/controller/service.go new file mode 100644 index 0000000..5c747f3 --- /dev/null +++ b/internal/controller/service.go @@ -0,0 +1,108 @@ +package controller + +import ( + "context" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/logic" +) + +// ============================================================================ +// 软件开发服务二期控制器(迭代-2026-09-30) +// +// 与 goods 同款拆分(同一 controller 对象不能同时 Bind 到 UserAuth / AdminAuth 两组): +// - Service 用户端(介绍页 / 作品 / 报价 / 站内信,需用户登录) +// - AdminService 管理端(模块 / 作品 / 报价处理) +// ============================================================================ + +// cService 用户端软件开发服务接口 +type cService struct{} + +// cAdminService 管理端软件开发服务接口 +type cAdminService struct{} + +var ( + Service = &cService{} + AdminService = &cAdminService{} +) + +// ---- 用户端:介绍页 / 作品 ---- + +func (c *cService) ServicePage(ctx context.Context, req *userv1.ServicePageReq) (res *userv1.ServicePageRes, err error) { + return logic.ServicePage(ctx) +} + +func (c *cService) WorksList(ctx context.Context, req *userv1.WorksListReq) (res *userv1.WorksListRes, err error) { + return logic.ServiceWorks(ctx, req) +} + +func (c *cService) WorkDetail(ctx context.Context, req *userv1.WorkDetailReq) (res *userv1.WorkDetailRes, err error) { + return logic.ServiceWorkDetail(ctx, req.Id) +} + +// ---- 用户端:报价申请 ---- + +func (c *cService) QuoteSubmit(ctx context.Context, req *userv1.QuoteSubmitReq) (res *userv1.QuoteSubmitRes, err error) { + return logic.ServiceQuoteSubmit(ctx, req) +} + +func (c *cService) QuoteMine(ctx context.Context, req *userv1.QuoteMineReq) (res *userv1.QuoteMineRes, err error) { + return logic.ServiceQuoteMine(ctx, req.Limit) +} + +// ---- 用户端:站内信 ---- + +func (c *cService) MessageList(ctx context.Context, req *userv1.MessageListReq) (res *userv1.MessageListRes, err error) { + return logic.MessageList(ctx, req.Limit) +} + +func (c *cService) MessageUnread(ctx context.Context, req *userv1.MessageUnreadReq) (res *userv1.MessageUnreadRes, err error) { + return logic.MessageUnreadCount(ctx) +} + +func (c *cService) MessageRead(ctx context.Context, req *userv1.MessageReadReq) (res *userv1.MessageReadRes, err error) { + return logic.MessageMarkRead(ctx, req.Id) +} + +// ---- 管理端:介绍页模块 ---- + +func (c *cAdminService) BlockList(ctx context.Context, req *adminv1.AdminServiceBlockListReq) (res *adminv1.AdminServiceBlockListRes, err error) { + return logic.AdminServiceBlockList(ctx) +} + +func (c *cAdminService) BlockSave(ctx context.Context, req *adminv1.AdminServiceBlockSaveReq) (res *adminv1.AdminServiceBlockSaveRes, err error) { + return logic.AdminServiceBlockSave(ctx, req) +} + +func (c *cAdminService) BlockDelete(ctx context.Context, req *adminv1.AdminServiceBlockDeleteReq) (res *adminv1.AdminServiceBlockDeleteRes, err error) { + return logic.AdminServiceBlockDelete(ctx, req.Id) +} + +// ---- 管理端:作品案例 ---- + +func (c *cAdminService) WorkList(ctx context.Context, req *adminv1.AdminServiceWorkListReq) (res *adminv1.AdminServiceWorkListRes, err error) { + return logic.AdminServiceWorkList(ctx, req) +} + +func (c *cAdminService) WorkDetail(ctx context.Context, req *adminv1.AdminServiceWorkDetailReq) (res *adminv1.AdminServiceWorkDetailRes, err error) { + return logic.AdminServiceWorkDetail(ctx, req.Id) +} + +func (c *cAdminService) WorkSave(ctx context.Context, req *adminv1.AdminServiceWorkSaveReq) (res *adminv1.AdminServiceWorkSaveRes, err error) { + return logic.AdminServiceWorkSave(ctx, req) +} + +func (c *cAdminService) WorkDelete(ctx context.Context, req *adminv1.AdminServiceWorkDeleteReq) (res *adminv1.AdminServiceWorkDeleteRes, err error) { + return logic.AdminServiceWorkDelete(ctx, req.Id) +} + +// ---- 管理端:报价申请 ---- + +func (c *cAdminService) QuoteList(ctx context.Context, req *adminv1.AdminServiceQuoteListReq) (res *adminv1.AdminServiceQuoteListRes, err error) { + return logic.AdminServiceQuoteList(ctx, req) +} + +func (c *cAdminService) QuoteHandle(ctx context.Context, req *adminv1.AdminServiceQuoteHandleReq) (res *adminv1.AdminServiceQuoteHandleRes, err error) { + return logic.AdminServiceQuoteHandle(ctx, req) +} diff --git a/internal/controller/setting.go b/internal/controller/setting.go new file mode 100644 index 0000000..2ba5b9a --- /dev/null +++ b/internal/controller/setting.go @@ -0,0 +1,26 @@ +package controller + +import ( + "context" + + adminv1 "tool-api/api/admin/v1" + "tool-api/internal/logic" +) + +// ============================================================================ +// 系统配置控制器(迭代-2026-09-29):白名单键的读取 / 保存 +// ============================================================================ + +// cAdminSetting 管理端系统配置接口(需管理员登录;读写都归 admin:manage,仅超管) +type cAdminSetting struct{} + +// AdminSetting 管理端系统配置对象(挂 AdminAuth 组) +var AdminSetting = &cAdminSetting{} + +func (c *cAdminSetting) SettingGet(ctx context.Context, req *adminv1.AdminSettingGetReq) (res *adminv1.AdminSettingGetRes, err error) { + return logic.AdminSettingGet(ctx) +} + +func (c *cAdminSetting) SettingSave(ctx context.Context, req *adminv1.AdminSettingSaveReq) (res *adminv1.AdminSettingSaveRes, err error) { + return logic.AdminSettingSave(ctx, req.Key, req.Value) +} diff --git a/internal/logic/admin.go b/internal/logic/admin.go index 631a8c1..2653730 100644 --- a/internal/logic/admin.go +++ b/internal/logic/admin.go @@ -184,7 +184,7 @@ func AdminUserList(ctx context.Context, req *v1.UserListReq) (*v1.UserListRes, e item := v1.AdminUserItem{ Id: r["id"].Int64(), Nickname: r["nickname"].String(), - AvatarUrl: r["avatar_url"].String(), + AvatarUrl: absoluteURL(ctx, r["avatar_url"].String()), Openid: r["openid"].String(), LevelKey: r["level_key"].String(), LevelName: levelNames[r["level_key"].String()], @@ -811,6 +811,14 @@ func AdminFeedbackHandle(ctx context.Context, id int64, reply string) (*v1.Feedb if len(reply) > 1000 { reply = reply[:1000] } + // 先取反馈归属(站内信收件人);反馈不存在直接报错,避免把状态写到空气里 + fb, err := g.Model(consts.TableFeedbacks).Where("id", id).One() + if err != nil { + return nil, err + } + if fb.IsEmpty() { + return nil, gerror.New("反馈不存在") + } data := g.Map{"status": 1} if reply != "" { data["reply_content"] = reply @@ -822,6 +830,8 @@ func AdminFeedbackHandle(ctx context.Context, id int64, reply string) (*v1.Feedb return nil, err } if reply != "" { + // 站内信:反馈收到官方回复(尽力而为,失败不影响回复本身) + PushMessage(ctx, fb["user_id"].Int64(), consts.MsgTypeFeedbackReplied, "你的反馈有了新回复", reply, "") WriteAudit(ctx, AuditEntry{ Action: "feedback.reply", TargetType: "feedback", diff --git a/internal/logic/admin_order.go b/internal/logic/admin_order.go index 6a5c190..0062008 100644 --- a/internal/logic/admin_order.go +++ b/internal/logic/admin_order.go @@ -102,7 +102,7 @@ func AdminOrderList(ctx context.Context, req *v1.OrderListReq) (*v1.OrderListRes if u, ok := userInfo[uid]; ok { item.Nickname = u["nickname"].String() item.Username = u["username"].String() - item.AvatarUrl = u["avatar_url"].String() + item.AvatarUrl = absoluteURL(ctx, u["avatar_url"].String()) } item.Title = orderTitle(orderType, r["plan_key"].String(), r["pack_key"].String(), r["tool_key"].String(), planNames, packNames, toolNames) diff --git a/internal/logic/admin_perm.go b/internal/logic/admin_perm.go index cdcd5ae..f4b674e 100644 --- a/internal/logic/admin_perm.go +++ b/internal/logic/admin_perm.go @@ -33,12 +33,15 @@ const ( // 订单域:查看 / 退款(退款为可逆性资金动作,单独权限点便于收口) PermOrderRead = "order:read" PermOrderWrite = "order:write" + // 软件开发服务 / 附件管理(迭代-2026-09-29) + PermGoodsWrite = "goods:write" // 服务商品新增/编辑/删除 + PermAttachmentWrite = "attachment:write" // 附件删除与管理端上传(封面/附件) ) // rolePermissions 角色 → 权限点集合。 // // 超管:全部 -// 运营:内容读写(用户/工具/模块/等级/反馈/额度/年会/订单),不含管理员管理与审计 +// 运营:内容读写(用户/工具/模块/等级/反馈/额度/年会/订单/服务商品/附件),不含管理员管理与审计 // 只读:无写权限,仅可查看(含订单查看) var rolePermissions = map[string]map[string]bool{ consts.AdminRoleSuper: { @@ -46,11 +49,13 @@ var rolePermissions = map[string]map[string]bool{ PermFeedbackWrite: true, PermQuotaWrite: true, PermEventWrite: true, PermAdminManage: true, PermAuditRead: true, PermOrderRead: true, PermOrderWrite: true, + PermGoodsWrite: true, PermAttachmentWrite: true, }, consts.AdminRoleOperator: { PermUserWrite: true, PermToolWrite: true, PermModuleWrite: true, PermLevelWrite: true, PermFeedbackWrite: true, PermQuotaWrite: true, PermEventWrite: true, PermOrderRead: true, PermOrderWrite: true, + PermGoodsWrite: true, PermAttachmentWrite: true, }, consts.AdminRoleReadonly: { PermOrderRead: true, @@ -101,6 +106,12 @@ func permissionForRoute(method, path string) string { if strings.HasPrefix(path, "/audit/") { return PermAuditRead } + // 管理端服务/附件的查看仅要求登录(与优惠码列表同策略;super/operator/readonly 均可看), + // 必须先于 /admin/ 的 admin:manage 兜底命中 + if strings.HasPrefix(path, "/admin/goods/") || strings.HasPrefix(path, "/admin/attachment/") || + strings.HasPrefix(path, "/admin/service/") { + return "" + } if strings.HasPrefix(path, "/admin/") { return PermAdminManage } @@ -135,6 +146,14 @@ func permissionForRoute(method, path string) string { case strings.HasPrefix(path, "/order/"): // 订单退款属可逆性资金动作,按「订单写」权限放行(运营+超管可写;只读不可写) return PermOrderWrite + case strings.HasPrefix(path, "/admin/goods/"): + return PermGoodsWrite + case strings.HasPrefix(path, "/admin/service/"): + // 软件开发服务域(介绍页模块 / 作品案例 / 报价处理)统一归「服务写」权限点 + return PermGoodsWrite + case path == "/admin/upload", strings.HasPrefix(path, "/admin/attachment/"): + // 上传与附件删除同归「附件写」:封面在服务编辑内上传,运营角色两者都有 + return PermAttachmentWrite case strings.HasPrefix(path, "/admin/"): return PermAdminManage case strings.HasPrefix(path, "/audit/"): diff --git a/internal/logic/admin_perm_test.go b/internal/logic/admin_perm_test.go index f6ffb9f..3086eff 100644 --- a/internal/logic/admin_perm_test.go +++ b/internal/logic/admin_perm_test.go @@ -48,15 +48,17 @@ func TestHasPermission(t *testing.T) { // TestPermissionsOf 权限点列表稳定且有序。 func TestPermissionsOf(t *testing.T) { - if n := len(PermissionsOf(consts.AdminRoleSuper)); n != 11 { - t.Fatalf("超管权限点应为 11,得 %d", n) + // 迭代-2026-09-29:新增 goods:write / attachment:write(11 → 13) + if n := len(PermissionsOf(consts.AdminRoleSuper)); n != 13 { + t.Fatalf("超管权限点应为 13,得 %d", n) } if n := len(PermissionsOf(consts.AdminRoleReadonly)); n != 1 { t.Fatalf("只读权限点应为 1(仅 order:read),得 %d", n) } pos := PermissionsOf(consts.AdminRoleOperator) - if len(pos) != 9 { - t.Fatalf("运营权限点应为 9,得 %d", len(pos)) + // 迭代-2026-09-29:运营同步获得 goods:write / attachment:write(9 → 11) + if len(pos) != 11 { + t.Fatalf("运营权限点应为 11,得 %d", len(pos)) } for i := 1; i < len(pos); i++ { if pos[i-1] > pos[i] { @@ -85,6 +87,16 @@ func TestPermissionForRoute(t *testing.T) { {http.MethodPost, "/admin/save", PermAdminManage}, {http.MethodPost, "/audit/record", ""}, // 上报审计仅需登录(≠读取审计) {http.MethodPost, "/order/refund", PermOrderWrite}, + // 迭代-2026-09-29:软件开发服务 / 附件 / 系统配置 + {http.MethodPost, "/admin/goods/save", PermGoodsWrite}, + {http.MethodPost, "/admin/goods/delete", PermGoodsWrite}, + {http.MethodPost, "/admin/upload", PermAttachmentWrite}, + {http.MethodPost, "/admin/attachment/delete", PermAttachmentWrite}, + {http.MethodPost, "/admin/setting/save", PermAdminManage}, + // 服务/附件列表仅需登录(先于 /admin/ 的 admin:manage 兜底命中) + {http.MethodGet, "/admin/goods/list", ""}, + {http.MethodGet, "/admin/attachment/list", ""}, + {http.MethodGet, "/admin/setting/get", PermAdminManage}, // 只读接口仅需登录 {http.MethodGet, "/user/list", ""}, {http.MethodGet, "/dashboard/stats", ""}, diff --git a/internal/logic/app_config.go b/internal/logic/app_config.go new file mode 100644 index 0000000..6d4f398 --- /dev/null +++ b/internal/logic/app_config.go @@ -0,0 +1,40 @@ +package logic + +import ( + "context" + "encoding/json" + + "github.com/gogf/gf/v2/frame/g" + + userv1 "tool-api/api/user/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 应用配置(迭代-2026-09-30):界面级轻量配置下发 +// +// 数据源是 settings 表(约定 S9),这里只做「解析 + URL 拼接」,不引入新表。 +// 解析失败一律回落空 map(fail-open):配置坏了不能把首页打挂。 +// ============================================================================ + +// AppConfig 下发应用配置(首页便捷功能卡背景图等)。 +// covers 值为相对路径,输出统一 absoluteURL 拼接(图片域名不落库)。 +func AppConfig(ctx context.Context) (*userv1.AppConfigRes, error) { + res := &userv1.AppConfigRes{FeatureCovers: map[string]string{}} + raw := SettingValue(ctx, consts.SettingHomeFeatureCovers) + if raw == "" { + return res, nil + } + paths := map[string]string{} + if err := json.Unmarshal([]byte(raw), &paths); err != nil { + g.Log().Warningf(ctx, "[appconfig] %s 解析失败,回落空配置: %v", consts.SettingHomeFeatureCovers, err) + return res, nil + } + for key, p := range paths { + if key == "" || p == "" { + continue + } + res.FeatureCovers[key] = absoluteURL(ctx, p) + } + return res, nil +} diff --git a/internal/logic/attachment.go b/internal/logic/attachment.go new file mode 100644 index 0000000..4890fc1 --- /dev/null +++ b/internal/logic/attachment.go @@ -0,0 +1,165 @@ +package logic + +import ( + "context" + "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" + + adminv1 "tool-api/api/admin/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 附件管理(迭代-2026-09-29) +// +// 上传统一登记 attachments 表(头像/封面/通用附件都在 recordAttachment 落账), +// 本文件提供后台的「查看 / 删除」。删除语义: +// - 记录一律软删(deleted_at,保留审计线索); +// - 文件本体只在「无任何引用」时才落盘删除(md5 去重意味着同一文件可能被 +// 多条记录 / 用户头像 / 服务封面共享,盲删会把这些引用全部打断)。 +// ============================================================================ + +var imageExtSet = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true, +} + +// AdminAttachmentList 附件列表(上传日期倒序) +func AdminAttachmentList(ctx context.Context, req *adminv1.AdminAttachmentListReq) (*adminv1.AdminAttachmentListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableAttachments) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + m = m.Where("file_name LIKE ?", "%"+kw+"%") + } + if scene := strings.TrimSpace(req.Scene); scene != "" { + m = m.Where("scene", scene) + } + 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 + } + // 批量做引用检查(避免逐行查询):同路径的其他未删记录 / 用户头像 / 服务封面 + paths := make([]string, 0, len(records)) + for _, r := range records { + paths = append(paths, r["file_path"].String()) + } + refMap := attachmentRefCounts(ctx, paths) + list := make([]adminv1.AdminAttachmentItem, 0, len(records)) + for _, r := range records { + path := r["file_path"].String() + ext := r["ext"].String() + list = append(list, adminv1.AdminAttachmentItem{ + Id: r["id"].Int64(), + Scene: r["scene"].String(), + FileName: r["file_name"].String(), + FilePath: path, + Url: absoluteURL(ctx, path), + Ext: ext, + SizeBytes: r["size_bytes"].Int64(), + Md5: r["md5"].String(), + UploaderId: r["uploader_id"].Int64(), + UploaderType: r["uploader_type"].String(), + // 计数含本记录自身(路径至少被自己引用)→ >1 才代表还有其他引用方; + // 删除流程的检查发生在软删之后,语义见 AdminAttachmentDelete + IsReferenced: refMap[path] > 1, + IsImage: imageExtSet[ext], + CreatedAt: timeStr(r["created_at"].GTime()), + }) + } + return &adminv1.AdminAttachmentListRes{List: list, Total: total}, nil +} + +// AdminAttachmentDelete 删除附件:记录软删;文件本体无引用时落盘删除。 +func AdminAttachmentDelete(ctx context.Context, id int64) (*adminv1.AdminAttachmentDeleteRes, error) { + r, err := g.Model(consts.TableAttachments).Where("id", id).One() + if err != nil { + return nil, err + } + if r.IsEmpty() { + return nil, gerror.New("附件不存在或已删除") + } + path := r["file_path"].String() + if _, err = g.Model(consts.TableAttachments).Where("id", id).Data(g.Map{ + "deleted_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + res := &adminv1.AdminAttachmentDeleteRes{} + // 引用检查在软删之后:排除本记录自身,剩余引用 >0 则保留文件本体 + if refs := attachmentRefCounts(ctx, []string{path}); refs[path] == 0 { + if disk, ok := staticPathToDisk(path); ok && gfile.Exists(disk) { + if err = gfile.Remove(disk); err != nil { + // 文件删除失败不回滚记录删除:记录已隐藏,文件成为孤儿可人工清理 + g.Log().Warningf(ctx, "[attachment] 删除文件失败 path=%s err=%v", path, err) + } else { + res.RemovedFile = true + } + } else { + // 文件本就不在(历史遗留/多实例部署),记录删除即达成目的 + res.RemovedFile = true + } + } + WriteAudit(ctx, AuditEntry{ + Action: "attachment.delete", + TargetType: "attachment", + TargetId: auditId(id), + Before: g.Map{"file_path": path, "file_name": r["file_name"].String()}, + Result: consts.AuditResultSuccess, + }) + return res, nil +} + +// attachmentRefCounts 统计每个路径的「存活引用数」: +// 其他未删附件记录(md5 去重共享同一落盘文件)+ 用户头像(存量含绝对 URL,用 LIKE 收敛)+ 服务封面。 +func attachmentRefCounts(ctx context.Context, paths []string) map[string]int { + out := make(map[string]int, len(paths)) + if len(paths) == 0 { + return out + } + for _, p := range paths { + if p != "" { + out[p] = 0 + } + } + // 同路径的其他未删附件记录 + records, err := g.Model(consts.TableAttachments). + WhereIn("file_path", paths). + WhereNull("deleted_at"). + Fields("file_path, COUNT(*) AS n"). + Group("file_path"). + All() + if err == nil { + for _, r := range records { + out[r["file_path"].String()] += r["n"].Int() + } + } else { + g.Log().Warningf(ctx, "[attachment] 引用检查(附件)失败: %v", err) + } + // 用户头像:LIKE '%path%' 兼容存量绝对 URL 写法 + for _, p := range paths { + if p == "" { + continue + } + n, err := g.Model(consts.TableUsers).Where("avatar_url LIKE ?", "%"+p+"%").WhereNull("deleted_at").Count() + if err == nil && n > 0 { + out[p] += n + } else if err != nil { + g.Log().Warningf(ctx, "[attachment] 引用检查(头像)失败: %v", err) + } + // 服务封面 + n, err = g.Model(consts.TableGoods).Where("cover_path", p).WhereNull("deleted_at").Count() + if err == nil && n > 0 { + out[p] += n + } else if err != nil { + g.Log().Warningf(ctx, "[attachment] 引用检查(封面)失败: %v", err) + } + } + return out +} diff --git a/internal/logic/goods.go b/internal/logic/goods.go new file mode 100644 index 0000000..b91b3d2 --- /dev/null +++ b/internal/logic/goods.go @@ -0,0 +1,240 @@ +package logic + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 软件开发服务(迭代-2026-09-29) +// +// 分类固定四类(网站/小程序/App/桌面),category 存 tinyint;content_md 只存 +// Markdown 原文(管理端编辑预览、小程序端解析渲染,后端不做 HTML 转换)。 +// 封面 cover_path 库内存相对路径,输出统一 absoluteURL 拼接(图片域名不落库)。 +// ============================================================================ + +// goodsSortWhitelist 管理端列表排序白名单(防注入) +var goodsSortWhitelist = map[string]string{ + "id": "id", + "category": "category", + "sort": "sort", + "price": "price_cents", + "is_enabled": "is_enabled", + "created_at": "created_at", +} + +// GoodsList C 端服务列表(仅上架;sort 升序、id 降序) +func GoodsList(ctx context.Context, req *userv1.GoodsListReq) (*userv1.GoodsListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableGoods).Where("is_enabled", 1) + if req.Category > 0 { + m = m.Where("category", req.Category) + } + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := m.Page(page, pageSize).Order("sort asc, id desc").All() + if err != nil { + return nil, err + } + list := make([]userv1.GoodsItem, 0, len(records)) + for _, r := range records { + list = append(list, goodsItemOf(ctx, r)) + } + return &userv1.GoodsListRes{List: list, Total: total}, nil +} + +// GoodsDetail C 端服务详情(仅上架;下架/不存在同义报错,避免探测下架内容) +func GoodsDetail(ctx context.Context, id int64) (*userv1.GoodsDetailRes, error) { + r, err := g.Model(consts.TableGoods).Where("id", id).Where("is_enabled", 1).One() + if err != nil { + return nil, err + } + if r.IsEmpty() { + return nil, gerror.New("服务不存在或已下架") + } + return &userv1.GoodsDetailRes{ + Id: r["id"].Int64(), + Category: r["category"].Int(), + CategoryName: consts.GoodsCategoryNameOf(r["category"].Int()), + Title: r["title"].String(), + Summary: r["summary"].String(), + ContentMd: r["content_md"].String(), + CoverUrl: absoluteURL(ctx, r["cover_path"].String()), + PriceCents: r["price_cents"].Int64(), + PriceNote: r["price_note"].String(), + UpdatedAt: timeStr(r["updated_at"].GTime()), + }, nil +} + +// AdminGoodsList 管理端服务列表(含下架) +func AdminGoodsList(ctx context.Context, req *adminv1.AdminGoodsListReq) (*adminv1.AdminGoodsListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableGoods) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + m = m.Where("title LIKE ?", "%"+kw+"%") + } + if req.Category > 0 { + m = m.Where("category", req.Category) + } + if req.IsEnabled != nil { + m = m.Where("is_enabled", *req.IsEnabled) + } + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := applySort(m.Page(page, pageSize), req.SortBy, req.SortOrder, goodsSortWhitelist, "sort asc, id desc").All() + if err != nil { + return nil, err + } + list := make([]adminv1.AdminGoodsItem, 0, len(records)) + for _, r := range records { + list = append(list, adminv1.AdminGoodsItem{ + Id: r["id"].Int64(), + Category: r["category"].Int(), + CategoryName: consts.GoodsCategoryNameOf(r["category"].Int()), + Title: r["title"].String(), + Summary: r["summary"].String(), + CoverPath: r["cover_path"].String(), + CoverUrl: absoluteURL(ctx, r["cover_path"].String()), + PriceCents: r["price_cents"].Int64(), + PriceNote: r["price_note"].String(), + Sort: r["sort"].Int(), + IsEnabled: r["is_enabled"].Int(), + CreatedAt: timeStr(r["created_at"].GTime()), + UpdatedAt: timeStr(r["updated_at"].GTime()), + }) + } + return &adminv1.AdminGoodsListRes{List: list, Total: total}, nil +} + +// AdminGoodsDetail 管理端服务详情(编辑回填,含 content_md) +func AdminGoodsDetail(ctx context.Context, id int64) (*adminv1.AdminGoodsDetailRes, error) { + r, err := g.Model(consts.TableGoods).Where("id", id).One() + if err != nil { + return nil, err + } + if r.IsEmpty() { + return nil, gerror.New("服务不存在") + } + return &adminv1.AdminGoodsDetailRes{ + Id: r["id"].Int64(), + Category: r["category"].Int(), + CategoryName: consts.GoodsCategoryNameOf(r["category"].Int()), + Title: r["title"].String(), + Summary: r["summary"].String(), + ContentMd: r["content_md"].String(), + CoverPath: r["cover_path"].String(), + CoverUrl: absoluteURL(ctx, r["cover_path"].String()), + PriceCents: r["price_cents"].Int64(), + PriceNote: r["price_note"].String(), + Sort: r["sort"].Int(), + IsEnabled: r["is_enabled"].Int(), + }, nil +} + +// AdminGoodsSave 新增/编辑服务。封面写入前归一化为相对路径(图片域名不落库)。 +// TZ-01:datetime 写库一律 gdb.Raw("NOW()")。 +func AdminGoodsSave(ctx context.Context, req *adminv1.AdminGoodsSaveReq) (*adminv1.AdminGoodsSaveRes, error) { + title := strings.TrimSpace(req.Title) + if title == "" { + return nil, gerror.New("请填写服务名称") + } + data := g.Map{ + "category": req.Category, + "title": title, + "summary": strings.TrimSpace(req.Summary), + "content_md": req.ContentMd, + "price_cents": req.PriceCents, + "price_note": strings.TrimSpace(req.PriceNote), + "cover_path": normalizeToRelativePath(req.CoverPath), + "sort": req.Sort, + "is_enabled": req.IsEnabled, + } + if req.Id > 0 { + exist, err := g.Model(consts.TableGoods).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("服务不存在") + } + data["updated_at"] = gdb.Raw("NOW()") + if _, err = g.Model(consts.TableGoods).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "goods.save", + TargetType: "goods", + TargetId: auditId(req.Id), + Before: g.Map{"title": exist["title"].String(), "is_enabled": exist["is_enabled"].Int()}, + After: g.Map{"title": title, "is_enabled": req.IsEnabled}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminGoodsSaveRes{Id: req.Id}, nil + } + data["created_at"] = gdb.Raw("NOW()") + data["updated_at"] = gdb.Raw("NOW()") + res, err := g.Model(consts.TableGoods).Data(data).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + WriteAudit(ctx, AuditEntry{ + Action: "goods.save", + TargetType: "goods", + TargetId: auditId(id), + After: g.Map{"title": title, "is_enabled": req.IsEnabled}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminGoodsSaveRes{Id: id}, nil +} + +// AdminGoodsDelete 软删除服务(deleted_at 置时间;GoFrame 对带 deleted_at 的表自动过滤已删行) +func AdminGoodsDelete(ctx context.Context, id int64) (*adminv1.AdminGoodsDeleteRes, error) { + exist, err := g.Model(consts.TableGoods).Where("id", id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("服务不存在") + } + if _, err = g.Model(consts.TableGoods).Where("id", id).Data(g.Map{ + "deleted_at": gdb.Raw("NOW()"), + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "goods.delete", + TargetType: "goods", + TargetId: auditId(id), + Before: g.Map{"title": exist["title"].String()}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminGoodsDeleteRes{}, nil +} + +// goodsItemOf 列表行 → C 端输出 +func goodsItemOf(ctx context.Context, r gdb.Record) userv1.GoodsItem { + return userv1.GoodsItem{ + Id: r["id"].Int64(), + Category: r["category"].Int(), + CategoryName: consts.GoodsCategoryNameOf(r["category"].Int()), + Title: r["title"].String(), + Summary: r["summary"].String(), + CoverUrl: absoluteURL(ctx, r["cover_path"].String()), + PriceCents: r["price_cents"].Int64(), + PriceNote: r["price_note"].String(), + } +} diff --git a/internal/logic/member.go b/internal/logic/member.go index 5d7111f..c156368 100644 --- a/internal/logic/member.go +++ b/internal/logic/member.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "math/rand" + "strconv" "time" "github.com/gogf/gf/v2/database/gdb" @@ -206,12 +207,22 @@ func MemberOrderCheck(ctx context.Context, outTradeNo string) (*v1.MemberOrderCh } // MemberOrders 我的订单列表 -func MemberOrders(ctx context.Context, limit int) (*v1.MemberOrdersRes, error) { +// MemberOrders 我的订单(limit + tab 状态页签:0全部 1待付款 2已生效 3退款)。 +// 「退款」页签 = 已退款(status=2) 或退过款但订单仍是其它终态(refund_status>0,如退款失败/退款中)。 +func MemberOrders(ctx context.Context, limit, tab int) (*v1.MemberOrdersRes, error) { if limit <= 0 || limit > 50 { limit = 20 } - records, err := g.Model(consts.TableMemberOrders). - Where("user_id", CtxUserId(ctx)). + m := g.Model(consts.TableMemberOrders).Where("user_id", CtxUserId(ctx)) + switch tab { + case 1: + m = m.Where("status", consts.OrderStatusPending) + case 2: + m = m.Where("status", consts.OrderStatusDelivered) + case 3: + m = m.Where("status=2 OR refund_status>0") + } + records, err := m. OrderDesc("id"). Limit(limit). All() @@ -440,6 +451,11 @@ func deliverOrder(ctx context.Context, outTradeNo, wxOrderId string) error { return gerror.Newf("订单 %s 有效天数异常: %d", outTradeNo, days) } + // 站内信文案(发货成功后与发货同事务写入:发了货必有消息) + msgTitle := "会员已开通" + msgContent := "你的会员权益已生效,快去解锁全部模块体验吧" + msgLink := "/pkg-member/member/orders" + // 两类商品在同一事务里分支发货,幂等仍由上面的 status 判定保证 switch row["order_type"].Int() { case consts.OrderTypeQuota: @@ -454,6 +470,9 @@ func deliverOrder(ctx context.Context, outTradeNo, wxOrderId string) error { if err != nil { return err } + msgTitle = "次数包已到账" + msgContent = "你的次数包已到账:+" + strconv.Itoa(times) + " 次,可直接使用" + msgLink = "/pkg-member/quota/my" g.Log().Infof(ctx, "[quota] 次数包发货完成 userId=%d outTradeNo=%s tool=%s times=%d expire=%s", userId, outTradeNo, toolKey, times, expireAt.String()) default: @@ -487,6 +506,19 @@ func deliverOrder(ctx context.Context, outTradeNo, wxOrderId string) error { }).Update(); err != nil { return err } + + // 站内信:订单生效通知(同事务写入,TZ-01 用 NOW()) + if _, err = tx.Model(consts.TableUserMessages).Data(g.Map{ + "user_id": userId, + "type": consts.MsgTypeOrderDelivered, + "title": msgTitle, + "content": msgContent, + "link": msgLink, + "is_read": 0, + "created_at": gdb.Raw("NOW()"), + }).Insert(); err != nil { + return err + } return nil }) } diff --git a/internal/logic/message.go b/internal/logic/message.go new file mode 100644 index 0000000..6597de7 --- /dev/null +++ b/internal/logic/message.go @@ -0,0 +1,223 @@ +package logic + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 站内信(迭代-2026-09-30) +// +// 业务事件统一经 PushMessage 落 user_messages(会员开通/次数包到账、报价回执、 +// 报价处理结果、反馈官方回复等)。推送是**尽力而为**:失败只告警,绝不让业务 +// 主流程(发货/报价/回复)因站内信失败而回滚 —— 唯一例外是发货事务内的写入 +// (用 tx 直插,与发货同事务保证原子,见 member.go deliverOrder)。 +// +// TZ-01:datetime 写库一律 gdb.Raw("NOW()")。 +// ============================================================================ + +// PushMessage 写一条站内信(尽力而为:失败告警不影响调用方)。 +// content 截断到 500 字、link 截断到 255 字(与表结构上限对齐)。 +func PushMessage(ctx context.Context, userId int64, msgType, title, content, link string) { + if userId <= 0 { + return + } + if len(content) > 500 { + content = content[:500] + } + if len(link) > 255 { + link = link[:255] + } + _, err := g.Model(consts.TableUserMessages).Data(g.Map{ + "user_id": userId, + "type": msgType, + "title": title, + "content": content, + "link": link, + "is_read": 0, + "created_at": gdb.Raw("NOW()"), + }).Insert() + if err != nil { + g.Log().Warningf(ctx, "[message] 站内信写入失败 userId=%d type=%s: %v", userId, msgType, err) + } +} + +// MessageList 我的消息(最近 N 条 + 未读数)。不自动置已读:红点语义是 +// 「先看到未读数 → 进消息中心 → 手动/进入后清零」,已读走 MessageMarkRead。 +func MessageList(ctx context.Context, limit int) (*userv1.MessageListRes, error) { + if limit <= 0 || limit > 50 { + limit = 20 + } + userId := CtxUserId(ctx) + records, err := g.Model(consts.TableUserMessages). + Where("user_id", userId). + OrderDesc("id"). + Limit(limit). + All() + if err != nil { + return nil, err + } + unread, err := g.Model(consts.TableUserMessages). + Where("user_id", userId). + Where("is_read", 0). + Count() + if err != nil { + return nil, err + } + list := make([]userv1.MessageItem, 0, len(records)) + for _, r := range records { + list = append(list, userv1.MessageItem{ + Id: r["id"].Int64(), + Type: r["type"].String(), + Title: r["title"].String(), + Content: r["content"].String(), + Link: r["link"].String(), + IsRead: r["is_read"].Int(), + CreatedAt: timeStr(r["created_at"].GTime()), + }) + } + return &userv1.MessageListRes{List: list, Unread: unread}, nil +} + +// MessageUnreadCount 未读消息数(首页 / 我的页徽标数据源) +func MessageUnreadCount(ctx context.Context) (*userv1.MessageUnreadRes, error) { + count, err := g.Model(consts.TableUserMessages). + Where("user_id", CtxUserId(ctx)). + Where("is_read", 0). + Count() + if err != nil { + return nil, err + } + return &userv1.MessageUnreadRes{Count: count}, nil +} + +// MessageMarkRead 标记已读:id>0 单条(校验归属);id=0 全部。 +func MessageMarkRead(ctx context.Context, id int64) (*userv1.MessageReadRes, error) { + m := g.Model(consts.TableUserMessages). + Where("user_id", CtxUserId(ctx)). + Where("is_read", 0) + if id > 0 { + m = m.Where("id", id) + } + if _, err := m.Data(g.Map{"is_read": 1}).Update(); err != nil { + return nil, err + } + return &userv1.MessageReadRes{}, nil +} + +// ============================================================================ +// 管理端:报价申请列表 / 处理 +// ============================================================================ + +// AdminServiceQuoteList 报价申请列表(分页 + 状态筛选,带用户昵称) +func AdminServiceQuoteList(ctx context.Context, req *adminv1.AdminServiceQuoteListReq) (*adminv1.AdminServiceQuoteListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableServiceQuotes) + 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 + } + // 用户昵称映射(用户可能已被软删,查不到时留空展示 id) + userIds := make([]int64, 0, len(records)) + for _, r := range records { + userIds = append(userIds, r["user_id"].Int64()) + } + nicknames := map[int64]string{} + 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() + } + } + } + list := make([]adminv1.AdminServiceQuoteItem, 0, len(records)) + for _, r := range records { + uid := r["user_id"].Int64() + list = append(list, adminv1.AdminServiceQuoteItem{ + Id: r["id"].Int64(), + UserId: uid, + UserNickname: nicknames[uid], + WorkId: r["work_id"].Int64(), + WorkTitle: r["work_title"].String(), + Contact: r["contact"].String(), + Requirement: r["requirement"].String(), + Budget: r["budget"].String(), + Status: r["status"].Int(), + StatusText: consts.QuoteStatusNameOf(r["status"].Int()), + QuoteCents: r["quote_cents"].Int64(), + QuoteNote: r["quote_note"].String(), + HandledAt: timeStr(r["handled_at"].GTime()), + CreatedAt: timeStr(r["created_at"].GTime()), + }) + } + return &adminv1.AdminServiceQuoteListRes{List: list, Total: total}, nil +} + +// AdminServiceQuoteHandle 处理报价申请:落状态/报价/说明 + 站内信通知用户。 +func AdminServiceQuoteHandle(ctx context.Context, req *adminv1.AdminServiceQuoteHandleReq) (*adminv1.AdminServiceQuoteHandleRes, error) { + r, err := g.Model(consts.TableServiceQuotes).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if r.IsEmpty() { + return nil, gerror.New("报价申请不存在") + } + note := strings.TrimSpace(req.QuoteNote) + if _, err = g.Model(consts.TableServiceQuotes).Where("id", req.Id).Data(g.Map{ + "status": req.Status, + "quote_cents": req.QuoteCents, + "quote_note": note, + "handled_by": CtxAdminId(ctx), + "handled_at": gdb.Raw("NOW()"), + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + + // 站内信:已报价/已成交/已关闭均通知(待处理回退不通知,避免噪声) + if req.Status != consts.QuoteStatusPending { + title := "你的报价申请有新进展" + switch req.Status { + case consts.QuoteStatusQuoted: + title = "报价已出,快来看看" + case consts.QuoteStatusDone: + title = "报价申请已成交" + case consts.QuoteStatusClosed: + title = "报价申请已关闭" + } + content := "你的报价申请(" + r["work_title"].String() + ")当前状态:" + consts.QuoteStatusNameOf(req.Status) + if req.QuoteCents > 0 { + content += ",报价 ¥" + centsYuanText(req.QuoteCents) + } + if note != "" { + content += "。" + note + } + PushMessage(ctx, r["user_id"].Int64(), consts.MsgTypeQuoteHandled, title, content, "/pkg-service/quotes") + } + + WriteAudit(ctx, AuditEntry{ + Action: "service.quote.handle", + TargetType: "service_quote", + TargetId: auditId(req.Id), + Before: g.Map{"status": r["status"].Int()}, + After: g.Map{"status": req.Status, "quote_cents": req.QuoteCents}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminServiceQuoteHandleRes{}, nil +} diff --git a/internal/logic/migrate.go b/internal/logic/migrate.go index c4a16be..5354dfa 100644 --- a/internal/logic/migrate.go +++ b/internal/logic/migrate.go @@ -307,6 +307,210 @@ func migrateTables() []migrateTable { "KEY `idx_coupon` (`coupon_id`)," + "KEY `idx_user_lock` (`user_id`,`used_order_no`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户持有的优惠券(含券面快照 + 占用/核销记录)'"}, + + // ===== 软件开发服务域(迭代-2026-09-29)===== + {"goods", "CREATE TABLE IF NOT EXISTS `goods` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`category` tinyint NOT NULL DEFAULT 1 COMMENT '分类 1网站开发 2小程序开发 3App开发 4桌面程序开发'," + + "`title` varchar(128) NOT NULL DEFAULT '' COMMENT '服务名称'," + + "`summary` varchar(500) NOT NULL DEFAULT '' COMMENT '简介(列表卡片展示)'," + + "`content_md` mediumtext COMMENT '详细说明 Markdown 原文,小程序端解析渲染'," + + "`price_cents` bigint NOT NULL DEFAULT 0 COMMENT '定价(分),0=面议'," + + "`price_note` varchar(64) NOT NULL DEFAULT '' COMMENT '价格备注(如「起」「/项目」)'," + + "`cover_path` varchar(255) NOT NULL DEFAULT '' COMMENT '封面相对路径(/static/...),库内不存域名'," + + "`sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前'," + + "`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '上架状态 0下架 1上架'," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," + + "PRIMARY KEY (`id`)," + + "KEY `idx_cat_sort` (`is_enabled`,`category`,`sort`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发服务商品'"}, + {"attachments", "CREATE TABLE IF NOT EXISTS `attachments` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`scene` varchar(32) NOT NULL DEFAULT '' COMMENT '上传场景 avatar/cover/attachment'," + + "`file_name` varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名'," + + "`file_path` varchar(255) NOT NULL DEFAULT '' COMMENT '相对路径(/static/...),库内不存域名'," + + "`ext` varchar(16) NOT NULL DEFAULT '' COMMENT '扩展名(小写含点)'," + + "`size_bytes` bigint NOT NULL DEFAULT 0 COMMENT '文件大小(字节)'," + + "`md5` char(32) NOT NULL DEFAULT '' COMMENT '内容 md5(落盘文件名)'," + + "`uploader_id` bigint NOT NULL DEFAULT 0 COMMENT '上传者 id(管理端或用户)'," + + "`uploader_type` varchar(16) NOT NULL DEFAULT 'admin' COMMENT '上传者类型 admin/user'," + + "`created_at` datetime DEFAULT NULL COMMENT '上传日期'," + + "`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除(记录删除,文件本体按引用检查)'," + + "PRIMARY KEY (`id`)," + + "KEY `idx_created` (`created_at`)," + + "KEY `idx_path` (`file_path`)," + + "KEY `idx_scene` (`scene`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传附件登记(上传日期/上传者/大小,供后台附件管理)'"}, + + // ===== 软件开发服务域二期:介绍页模块 / 作品案例 / 报价申请 / 站内信(迭代-2026-09-30)===== + {"service_blocks", "CREATE TABLE IF NOT EXISTS `service_blocks` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`kind` varchar(16) NOT NULL DEFAULT 'rich' COMMENT '模块类型 hero/rich/image_text/image_list/developer/works/goods'," + + "`title` varchar(128) NOT NULL DEFAULT '' COMMENT '标题'," + + "`subtitle` varchar(255) NOT NULL DEFAULT '' COMMENT '副标题'," + + "`image_path` varchar(255) NOT NULL DEFAULT '' COMMENT '主图相对路径(hero/image_text/developer 头像),库内不存域名'," + + "`images` text COMMENT '图片列表 JSON 数组(相对路径,image_list 用)'," + + "`content` mediumtext COMMENT '正文(Markdown 原文,C 端解析渲染)'," + + "`sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前'," + + "`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '启用 0停用 1启用'," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_sort` (`is_enabled`,`sort`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发服务介绍页模块(后台可编辑,预设排版布局)'"}, + {"service_works", "CREATE TABLE IF NOT EXISTS `service_works` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`category` tinyint NOT NULL DEFAULT 1 COMMENT '分类 1网站 2小程序 3App 4桌面(与 goods 对齐)'," + + "`title` varchar(128) NOT NULL DEFAULT '' COMMENT '作品名称'," + + "`summary` varchar(500) NOT NULL DEFAULT '' COMMENT '简介(列表卡片展示)'," + + "`content_md` mediumtext COMMENT '详情说明 Markdown 原文'," + + "`cover_path` varchar(255) NOT NULL DEFAULT '' COMMENT '封面相对路径'," + + "`images` text COMMENT '详情图集 JSON 数组(相对路径)'," + + "`price_cents` bigint NOT NULL DEFAULT 0 COMMENT '参考报价(分),0=面议'," + + "`price_note` varchar(64) NOT NULL DEFAULT '' COMMENT '报价备注(如「起」)'," + + "`sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前'," + + "`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '上架状态 0下架 1上架'," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," + + "PRIMARY KEY (`id`)," + + "KEY `idx_cat_sort` (`is_enabled`,`category`,`sort`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发作品案例'"}, + {"service_quotes", "CREATE TABLE IF NOT EXISTS `service_quotes` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`user_id` bigint NOT NULL," + + "`work_id` bigint NOT NULL DEFAULT 0 COMMENT '关联作品 id,0=通用申请'," + + "`work_title` varchar(128) NOT NULL DEFAULT '' COMMENT '作品名快照(作品改名/删除不影响申请单)'," + + "`contact` varchar(64) NOT NULL DEFAULT '' COMMENT '联系方式(微信/手机/QQ)'," + + "`requirement` varchar(1000) NOT NULL DEFAULT '' COMMENT '需求描述'," + + "`budget` varchar(64) NOT NULL DEFAULT '' COMMENT '预算范围(文案,如 5k-1w)'," + + "`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1已报价 2已成交 3已关闭'," + + "`quote_cents` bigint NOT NULL DEFAULT 0 COMMENT '报价金额(分),0=面议/未填'," + + "`quote_note` varchar(500) NOT NULL DEFAULT '' COMMENT '报价说明(用户端可见)'," + + "`handled_by` bigint NOT NULL DEFAULT 0 COMMENT '处理管理员 id,0=未处理'," + + "`handled_at` datetime DEFAULT NULL COMMENT '最近处理时间'," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_user` (`user_id`,`id`)," + + "KEY `idx_status` (`status`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发报价申请'"}, + {"user_messages", "CREATE TABLE IF NOT EXISTS `user_messages` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`user_id` bigint NOT NULL," + + "`type` varchar(32) NOT NULL DEFAULT 'system' COMMENT '类型 order_delivered/quote_submitted/quote_handled/feedback_replied/project_update/system'," + + "`title` varchar(128) NOT NULL DEFAULT '' COMMENT '消息标题'," + + "`content` varchar(500) NOT NULL DEFAULT '' COMMENT '消息正文'," + + "`link` varchar(255) NOT NULL DEFAULT '' COMMENT '跳转路径(小程序内路由,空=不跳转)'," + + "`is_read` tinyint NOT NULL DEFAULT 0 COMMENT '0未读 1已读'," + + "`created_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_user_read` (`user_id`,`is_read`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户站内信(会员开通/报价/反馈回复等通知)'"}, + + // ===== 软件开发域三期:开发项目(后台建单 → 用户扫码绑定 → 阶段/节点/交付物跟踪)===== + {"service_projects", "CREATE TABLE IF NOT EXISTS `service_projects` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`user_id` bigint NOT NULL DEFAULT 0 COMMENT '绑定用户 id,0=待扫码绑定'," + + "`bind_token` varchar(16) NOT NULL DEFAULT '' COMMENT '扫码绑定令牌(scene 携带,唯一)'," + + "`title` varchar(128) NOT NULL DEFAULT '' COMMENT '项目名称'," + + "`description` varchar(1000) NOT NULL DEFAULT '' COMMENT '需求描述'," + + "`price_cents` bigint NOT NULL DEFAULT 0 COMMENT '项目金额(分),0=面议'," + + "`price_note` varchar(64) NOT NULL DEFAULT '' COMMENT '金额备注'," + + "`due_note` varchar(64) NOT NULL DEFAULT '' COMMENT '交付周期文案(如「4 周」)'," + + "`status` tinyint NOT NULL DEFAULT 1 COMMENT '1进行中 2已交付 3已关闭'," + + "`code_url` varchar(255) NOT NULL DEFAULT '' COMMENT '绑定小程序码相对路径(/qrcode/...)'," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," + + "PRIMARY KEY (`id`)," + + "UNIQUE KEY `uk_bind_token` (`bind_token`)," + + "KEY `idx_user` (`user_id`,`id`)," + + "KEY `idx_status` (`status`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发项目订单'"}, + {"service_project_stages", "CREATE TABLE IF NOT EXISTS `service_project_stages` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`project_id` bigint NOT NULL," + + "`title` varchar(64) NOT NULL DEFAULT '' COMMENT '阶段名(如 原型设计/UI设计/开发/测试)'," + + "`status` tinyint NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已完成'," + + "`sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前'," + + "`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '0隐藏 1显示'," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_project` (`project_id`,`sort`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发项目阶段'"}, + {"service_project_nodes", "CREATE TABLE IF NOT EXISTS `service_project_nodes` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`project_id` bigint NOT NULL," + + "`stage_id` bigint NOT NULL," + + "`title` varchar(64) NOT NULL DEFAULT '' COMMENT '小节点(可勾选事项)'," + + "`is_done` tinyint NOT NULL DEFAULT 0 COMMENT '0未完成 1已完成'," + + "`done_at` datetime DEFAULT NULL COMMENT '完成时间'," + + "`sort` int NOT NULL DEFAULT 0," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_stage` (`stage_id`,`sort`,`id`)," + + "KEY `idx_project` (`project_id`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发项目阶段小节点'"}, + {"service_project_files", "CREATE TABLE IF NOT EXISTS `service_project_files` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`project_id` bigint NOT NULL," + + "`stage_id` bigint NOT NULL DEFAULT 0 COMMENT '所属阶段,0=项目级'," + + "`file_name` varchar(128) NOT NULL DEFAULT '' COMMENT '展示名(默认原始文件名)'," + + "`file_path` varchar(255) NOT NULL DEFAULT '' COMMENT '相对路径(/static/...),库内不存域名'," + + "`sort` int NOT NULL DEFAULT 0," + + "`created_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_stage` (`stage_id`,`sort`,`id`)," + + "KEY `idx_project` (`project_id`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发项目交付物(原型/UI 设计稿等图片)'"}, + + // ===== 软件开发域四期:维护模板 / 付款分期模板 / 项目分期(交付流程)===== + {"service_maintain_plans", "CREATE TABLE IF NOT EXISTS `service_maintain_plans` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`name` varchar(64) NOT NULL DEFAULT '' COMMENT '模板名(如 一年免费维护)'," + + "`free_years` int NOT NULL DEFAULT 0 COMMENT '免费维护年数,0=无免费'," + + "`yearly_fee_cents` bigint NOT NULL DEFAULT 0 COMMENT '后续维护费(元/年,分存储),0=免费无续费'," + + "`source_only` tinyint NOT NULL DEFAULT 0 COMMENT '1=仅交付源码无售后(不进维护期提醒)'," + + "`sort` int NOT NULL DEFAULT 0," + + "`is_enabled` tinyint NOT NULL DEFAULT 1," + + "`remark` varchar(255) NOT NULL DEFAULT ''," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_sort` (`is_enabled`,`sort`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维护模板(免费年限/年费/仅源码)'"}, + {"service_pay_plans", "CREATE TABLE IF NOT EXISTS `service_pay_plans` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`name` varchar(64) NOT NULL DEFAULT '' COMMENT '模板名(如 两期·上线前结清)'," + + "`schedule` varchar(500) NOT NULL DEFAULT '' COMMENT '分期 JSON:[{seq,percent,trigger}],trigger=start/online_before/online_after/mid_demo'," + + "`sort` int NOT NULL DEFAULT 0," + + "`is_enabled` tinyint NOT NULL DEFAULT 1," + + "`remark` varchar(255) NOT NULL DEFAULT ''," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_sort` (`is_enabled`,`sort`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='付款分期模板(定金/尾款比例节点)'"}, + {"service_project_pays", "CREATE TABLE IF NOT EXISTS `service_project_pays` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`project_id` bigint NOT NULL," + + "`seq` int NOT NULL DEFAULT 1 COMMENT '第几期'," + + "`percent` int NOT NULL DEFAULT 0 COMMENT '付款比例(%)'," + + "`amount_cents` bigint NOT NULL DEFAULT 0 COMMENT '本期金额(分)= 项目金额 × 比例'," + + "`trigger` varchar(16) NOT NULL DEFAULT 'start' COMMENT '触发节点 start/online_before/online_after/mid_demo'," + + "`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付'," + + "`paid_at` datetime DEFAULT NULL," + + "`paid_note` varchar(64) NOT NULL DEFAULT '' COMMENT '收款备注(如 微信收款)'," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_project` (`project_id`,`seq`,`id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发项目分期付款(后台确认到账)'"}, } } @@ -383,5 +587,14 @@ func migrateColumns() []migrateColumn { {"event_tables", "remark", "ALTER TABLE `event_tables` ADD COLUMN `remark` varchar(64) NOT NULL DEFAULT '' COMMENT '桌备注'"}, {"event_tables", "exclude_auto", "ALTER TABLE `event_tables` ADD COLUMN `exclude_auto` tinyint NOT NULL DEFAULT 0 COMMENT '1=不参与自动排序'"}, {"event_participants", "exclude_assign", "ALTER TABLE `event_participants` ADD COLUMN `exclude_assign` tinyint NOT NULL DEFAULT 0 COMMENT '1=不参与排座'"}, + + // ===== 开发项目四期:交付流程(维护模板 / 付款分期 / 验收维护期)===== + {"service_projects", "maintain_plan_id", "ALTER TABLE `service_projects` ADD COLUMN `maintain_plan_id` bigint NOT NULL DEFAULT 0 COMMENT '维护模板 id,0=未选择'"}, + {"service_projects", "pay_plan_id", "ALTER TABLE `service_projects` ADD COLUMN `pay_plan_id` bigint NOT NULL DEFAULT 0 COMMENT '付款分期模板 id,0=未选择'"}, + {"service_projects", "accepted", "ALTER TABLE `service_projects` ADD COLUMN `accepted` tinyint NOT NULL DEFAULT 0 COMMENT '0待验收 1已验收(用户手动确认)'"}, + {"service_projects", "accepted_at", "ALTER TABLE `service_projects` ADD COLUMN `accepted_at` datetime DEFAULT NULL COMMENT '用户确认验收时间(维护期起点,后台可调 maintain_start_at)'"}, + {"service_projects", "maintain_start_at", "ALTER TABLE `service_projects` ADD COLUMN `maintain_start_at` datetime DEFAULT NULL COMMENT '维护期起算日(默认=验收日,后台可调整)'"}, + {"service_projects", "maintain_paid_until", "ALTER TABLE `service_projects` ADD COLUMN `maintain_paid_until` datetime DEFAULT NULL COMMENT '维护费已缴至(免费期=起点+免费年数;续费+1年)'"}, + {"service_projects", "last_maintain_remind_at", "ALTER TABLE `service_projects` ADD COLUMN `last_maintain_remind_at` datetime DEFAULT NULL COMMENT '上次缴费提醒时间(隔 3 天一次的依据)'"}, } } diff --git a/internal/logic/project.go b/internal/logic/project.go new file mode 100644 index 0000000..47caa68 --- /dev/null +++ b/internal/logic/project.go @@ -0,0 +1,1116 @@ +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 +} diff --git a/internal/logic/project_flow.go b/internal/logic/project_flow.go new file mode 100644 index 0000000..5d0c5af --- /dev/null +++ b/internal/logic/project_flow.go @@ -0,0 +1,465 @@ +package logic + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + adminv1 "tool-api/api/admin/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 开发项目四期:维护模板 / 付款分期模板 / 分期到账 / 维护期 / 每日提醒扫描 +//(迭代-2026-09-30,交付流程:需求提交 → 功能确认 → 支付定金 → 开发 → 验收 → 维护期) +// ============================================================================ + +// ---------- 维护模板 ---------- + +// AdminMaintainPlanList 维护模板列表(含停用) +func AdminMaintainPlanList(ctx context.Context) (*adminv1.AdminMaintainPlanListRes, error) { + records, err := g.Model(consts.TableMaintainPlans).Order("sort asc, id asc").All() + if err != nil { + return nil, err + } + list := make([]adminv1.AdminMaintainPlanItem, 0, len(records)) + for _, r := range records { + list = append(list, adminv1.AdminMaintainPlanItem{ + Id: r["id"].Int64(), + Name: r["name"].String(), + FreeYears: r["free_years"].Int(), + YearlyFeeCents: r["yearly_fee_cents"].Int64(), + SourceOnly: r["source_only"].Int(), + Sort: r["sort"].Int(), + IsEnabled: r["is_enabled"].Int(), + Remark: r["remark"].String(), + }) + } + return &adminv1.AdminMaintainPlanListRes{List: list}, nil +} + +// AdminMaintainPlanSave 保存维护模板。仅源码模板不涉及免费年限与年费(统一清零)。 +func AdminMaintainPlanSave(ctx context.Context, req *adminv1.AdminMaintainPlanSaveReq) (*adminv1.AdminMaintainPlanSaveRes, error) { + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, gerror.New("请填写模板名称") + } + if req.SourceOnly == 1 { + req.YearlyFeeCents = 0 + req.FreeYears = 0 + } + data := g.Map{ + "name": name, + "free_years": req.FreeYears, + "yearly_fee_cents": req.YearlyFeeCents, + "source_only": req.SourceOnly, + "sort": req.Sort, + "is_enabled": req.IsEnabled, + "remark": strings.TrimSpace(req.Remark), + } + if req.Id > 0 { + data["updated_at"] = gdb.Raw("NOW()") + if _, err := g.Model(consts.TableMaintainPlans).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + return &adminv1.AdminMaintainPlanSaveRes{Id: req.Id}, nil + } + data["created_at"] = gdb.Raw("NOW()") + data["updated_at"] = gdb.Raw("NOW()") + res, err := g.Model(consts.TableMaintainPlans).Data(data).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return &adminv1.AdminMaintainPlanSaveRes{Id: id}, nil +} + +// AdminMaintainPlanDelete 删除维护模板(项目上的历史引用展示为「模板已删」)。 +func AdminMaintainPlanDelete(ctx context.Context, id int64) (*adminv1.AdminMaintainPlanDeleteRes, error) { + if _, err := g.Model(consts.TableMaintainPlans).Where("id", id).Delete(); err != nil { + return nil, err + } + return &adminv1.AdminMaintainPlanDeleteRes{}, nil +} + +// ---------- 付款分期模板 ---------- + +// AdminPayPlanList 分期模板列表(含停用) +func AdminPayPlanList(ctx context.Context) (*adminv1.AdminPayPlanListRes, error) { + records, err := g.Model(consts.TablePayPlans).Order("sort asc, id asc").All() + if err != nil { + return nil, err + } + list := make([]adminv1.AdminPayPlanItem, 0, len(records)) + for _, r := range records { + list = append(list, adminv1.AdminPayPlanItem{ + Id: r["id"].Int64(), + Name: r["name"].String(), + Schedule: r["schedule"].String(), + Sort: r["sort"].Int(), + IsEnabled: r["is_enabled"].Int(), + Remark: r["remark"].String(), + }) + } + return &adminv1.AdminPayPlanListRes{List: list}, nil +} + +// AdminPayPlanSave 保存分期模板(schedule JSON 校验:比例合计 100、触发节点合法)。 +func AdminPayPlanSave(ctx context.Context, req *adminv1.AdminPayPlanSaveReq) (*adminv1.AdminPayPlanSaveRes, error) { + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, gerror.New("请填写模板名称") + } + if _, err := parsePaySchedule(req.Schedule); err != nil { + return nil, err + } + data := g.Map{ + "name": name, + "schedule": strings.TrimSpace(req.Schedule), + "sort": req.Sort, + "is_enabled": req.IsEnabled, + "remark": strings.TrimSpace(req.Remark), + } + if req.Id > 0 { + data["updated_at"] = gdb.Raw("NOW()") + if _, err := g.Model(consts.TablePayPlans).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + return &adminv1.AdminPayPlanSaveRes{Id: req.Id}, nil + } + data["created_at"] = gdb.Raw("NOW()") + data["updated_at"] = gdb.Raw("NOW()") + res, err := g.Model(consts.TablePayPlans).Data(data).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return &adminv1.AdminPayPlanSaveRes{Id: id}, nil +} + +// AdminPayPlanDelete 删除分期模板。 +func AdminPayPlanDelete(ctx context.Context, id int64) (*adminv1.AdminPayPlanDeleteRes, error) { + if _, err := g.Model(consts.TablePayPlans).Where("id", id).Delete(); err != nil { + return nil, err + } + return &adminv1.AdminPayPlanDeleteRes{}, nil +} + +// ---------- 分期到账 / 维护期 ---------- + +// AdminProjectPayMark 标记分期到账并通知用户。 +func AdminProjectPayMark(ctx context.Context, req *adminv1.AdminProjectPayMarkReq) (*adminv1.AdminProjectPayMarkRes, error) { + pay, err := g.Model(consts.TableProjectPays).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if pay.IsEmpty() { + return nil, gerror.New("分期不存在") + } + if pay["status"].Int() == consts.PayStatusPaid { + return nil, gerror.New("该分期已是到账状态") + } + p, err := g.Model(consts.TableServiceProjects).Where("id", pay["project_id"].Int64()).One() + if err != nil { + return nil, err + } + if _, err = g.Model(consts.TableProjectPays).Where("id", req.Id).Data(g.Map{ + "status": consts.PayStatusPaid, + "paid_at": gdb.Raw("NOW()"), + "paid_note": strings.TrimSpace(req.PaidNote), + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + pushProjectUpdate(ctx, p["user_id"].Int64(), pay["project_id"].Int64(), + "付款已确认", + fmt.Sprintf("项目「%s」第 %d 期(¥%s,%s)已确认到账。", + p["title"].String(), pay["seq"].Int(), centsYuanText(pay["amount_cents"].Int64()), + consts.PayTriggerNameOf(pay["trigger"].String()))) + WriteAudit(ctx, AuditEntry{ + Action: "service.project.pay.mark", + TargetType: "service_project_pay", + TargetId: auditId(req.Id), + After: g.Map{"seq": pay["seq"].Int(), "amount_cents": pay["amount_cents"].Int64()}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminProjectPayMarkRes{}, nil +} + +// AdminProjectPayUnmark 撤销到账(误操作回退)。 +func AdminProjectPayUnmark(ctx context.Context, id int64) (*adminv1.AdminProjectPayUnmarkRes, error) { + if _, err := g.Model(consts.TableProjectPays).Where("id", id).Data(g.Map{ + "status": consts.PayStatusPending, + "paid_at": nil, + "paid_note": "", + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + return &adminv1.AdminProjectPayUnmarkRes{}, nil +} + +// AdminProjectMaintainRenew 维护费续收一年:已缴至顺延一年(从 max(已缴至, 今天) 起算)并通知用户。 +func AdminProjectMaintainRenew(ctx context.Context, id int64) (*adminv1.AdminProjectMaintainRenewRes, error) { + p, err := g.Model(consts.TableServiceProjects).Where("id", id).One() + if err != nil { + return nil, err + } + if p.IsEmpty() { + return nil, gerror.New("项目不存在") + } + if p["accepted"].Int() != 1 { + return nil, gerror.New("项目尚未验收,无维护期可续") + } + plan, err := g.Model(consts.TableMaintainPlans).Where("id", p["maintain_plan_id"].Int64()).One() + if err != nil { + return nil, err + } + if plan.IsEmpty() || plan["source_only"].Int() == 1 || plan["yearly_fee_cents"].Int64() <= 0 { + return nil, gerror.New("该项目的维护模板不涉及年度维护费") + } + base := p["maintain_paid_until"].GTime() + now := gtime.Now() + if base == nil || base.Before(now) { + base = now + } + paidUntil := base.AddDate(1, 0, 0) + if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{ + "maintain_paid_until": paidUntil, + "last_maintain_remind_at": nil, + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + pushProjectUpdate(ctx, p["user_id"].Int64(), id, + "维护费已续", + fmt.Sprintf("项目「%s」的维护服务已续费一年(¥%s/年),有效期至 %s。", + p["title"].String(), centsYuanText(plan["yearly_fee_cents"].Int64()), paidUntil.Format("Y-m-d"))) + WriteAudit(ctx, AuditEntry{ + Action: "service.project.maintain.renew", + TargetType: "service_project", + TargetId: auditId(id), + After: g.Map{"paid_until": paidUntil.String()}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminProjectMaintainRenewRes{PaidUntil: paidUntil.Format("Y-m-d")}, nil +} + +// AdminProjectMaintainAdjust 调整维护期起算日(重算已缴至:起点 + 免费年数)。 +func AdminProjectMaintainAdjust(ctx context.Context, req *adminv1.AdminProjectMaintainAdjustReq) (*adminv1.AdminProjectMaintainAdjustRes, error) { + p, err := g.Model(consts.TableServiceProjects).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if p.IsEmpty() { + return nil, gerror.New("项目不存在") + } + startAt, err := gtime.StrToTime(req.StartAt + " 00:00:00") + if err != nil { + return nil, gerror.New("起算日期格式不合法") + } + freeYears := 0 + sourceOnly := 0 + if p["maintain_plan_id"].Int64() > 0 { + plan, err := g.Model(consts.TableMaintainPlans).Where("id", p["maintain_plan_id"].Int64()).One() + if err == nil && !plan.IsEmpty() { + freeYears = plan["free_years"].Int() + sourceOnly = plan["source_only"].Int() + } + } + var paidUntil *gtime.Time + if sourceOnly != 1 && p["accepted"].Int() == 1 { + paidUntil = startAt.AddDate(freeYears, 0, 0) + } + if _, err = g.Model(consts.TableServiceProjects).Where("id", req.Id).Data(g.Map{ + "maintain_start_at": startAt, + "maintain_paid_until": paidUntil, + "last_maintain_remind_at": nil, + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "service.project.maintain.adjust", + TargetType: "service_project", + TargetId: auditId(req.Id), + After: g.Map{"start_at": startAt.String(), "paid_until": paidUntil}, + Result: consts.AuditResultSuccess, + }) + res := &adminv1.AdminProjectMaintainAdjustRes{} + if paidUntil != nil { + res.PaidUntil = paidUntil.Format("Y-m-d") + } + return res, nil +} + +// ---------- 提醒设置 ---------- + +// AdminMaintainRemindGet 提醒设置读取(settings)。 +func AdminMaintainRemindGet(ctx context.Context) (*adminv1.AdminMaintainRemindGetRes, error) { + days, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.remindDays").One() + notify, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.adminNotifyUserId").One() + res := &adminv1.AdminMaintainRemindGetRes{RemindDays: 15} + if !days.IsEmpty() { + if v := days["v"].Int(); v > 0 { + res.RemindDays = v + } + } + if !notify.IsEmpty() { + res.AdminNotifyUserId = notify["v"].Int64() + } + return res, nil +} + +// AdminMaintainRemindSave 提醒设置保存(remindDays / adminNotifyUserId)。 +func AdminMaintainRemindSave(ctx context.Context, req *adminv1.AdminMaintainRemindSaveReq) (*adminv1.AdminMaintainRemindSaveRes, error) { + for _, kv := range []struct{ k, v string }{ + {"service.maintain.remindDays", strconv.Itoa(req.RemindDays)}, + {"service.maintain.adminNotifyUserId", strconv.FormatInt(req.AdminNotifyUserId, 10)}, + } { + if err := settingUpsert(ctx, kv.k, kv.v); err != nil { + return nil, err + } + } + return &adminv1.AdminMaintainRemindSaveRes{}, nil +} + +// settingUpsert settings 单键写入(存在则更新,缺则补)。 +func settingUpsert(ctx context.Context, k, v string) error { + cnt, err := g.Model(consts.TableSettings).Where("k", k).Count() + if err != nil { + return err + } + if cnt > 0 { + _, err = g.Model(consts.TableSettings).Where("k", k).Data(g.Map{ + "v": v, + "updated_at": gdb.Raw("NOW()"), + }).Update() + return err + } + _, err = g.Model(consts.TableSettings).Data(g.Map{ + "k": k, + "v": v, + "updated_at": gdb.Raw("NOW()"), + }).Insert() + return err +} + +// ============================================================================ +// 维护费到期提醒:每天扫描(cmd.go 注册 cron) +// - 范围:已验收 + 维护模板有年费(非仅源码)+ 已缴至距今天数 ≤ remindDays(含已逾期) +// - 节奏:剩余 ≤3 天(含逾期)每天发;否则隔 3 天发一次(last_maintain_remind_at 判定) +// - 通知:用户(站内信 + 订阅消息);超管(settings adminNotifyUserId 指定的小程序账号, +// 同样站内信 + 订阅消息;未配置则只打日志——管理端开发项目页的待缴费横幅常驻可见) +// ============================================================================ + +// ScanMaintainReminders 每日维护费提醒扫描。 +func ScanMaintainReminders(ctx context.Context) { + remindDays, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.remindDays").One() + window := 15 + if !remindDays.IsEmpty() { + if v := remindDays["v"].Int(); v > 0 { + window = v + } + } + adminNotify, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.adminNotifyUserId").One() + adminUserId := int64(0) + if !adminNotify.IsEmpty() { + adminUserId = adminNotify["v"].Int64() + } + + records, err := g.Model(consts.TableServiceProjects+" p"). + LeftJoin(consts.TableMaintainPlans+" mp", "mp.id=p.maintain_plan_id"). + Where("p.accepted", 1). + Where("p.status", consts.ProjectStatusDone). + Where("mp.source_only", 0). + Where("mp.yearly_fee_cents > 0"). + Where("p.maintain_paid_until IS NOT NULL"). + Fields("p.*, mp.name as plan_name, mp.yearly_fee_cents"). + All() + if err != nil { + g.Log().Warningf(ctx, "[maintain-remind] 扫描失败: %v", err) + return + } + now := gtime.Now() + for _, r := range records { + paidUntil := r["maintain_paid_until"].GTime() + if paidUntil == nil { + continue + } + daysLeft := maintainDaysLeft(now, paidUntil) + if daysLeft > window { + continue + } + last := r["last_maintain_remind_at"].GTime() + if daysLeft > 3 && last != nil && now.Sub(last).Hours() < 72 { + continue // 未到最后 3 天时,隔 3 天发一次 + } + + title := "维护费缴纳提醒" + content := fmt.Sprintf("项目「%s」的维护服务将于 %s 到期(剩余 %d 天),年度维护费 ¥%s,请及时缴纳。", + r["title"].String(), paidUntil.Format("Y-m-d"), daysLeft, centsYuanText(r["yearly_fee_cents"].Int64())) + if daysLeft < 0 { + title = "维护费已到期" + content = fmt.Sprintf("项目「%s」的维护服务已于 %s 到期,为不影响售后服务,请尽快缴纳年度维护费 ¥%s。", + r["title"].String(), paidUntil.Format("Y-m-d"), centsYuanText(r["yearly_fee_cents"].Int64())) + } + link := fmt.Sprintf("/pkg-service/project-detail?id=%d", r["id"].Int64()) + userId := r["user_id"].Int64() + PushMessage(ctx, userId, consts.MsgTypeProjectUpdate, title, content, link) + + // 订阅消息(用户授权过才真正送达;模板未配置自动降级为站内信) + if openid := userOpenid(ctx, userId); openid != "" { + _ = SendProjectRemind(ctx, ProjectRemindSendReq{ + Openid: openid, Title: title, Content: content, Page: link, + }) + } + // 超管通知(settings 指定的小程序账号) + if adminUserId > 0 && adminUserId != userId { + PushMessage(ctx, adminUserId, consts.MsgTypeProjectUpdate, "【待缴费】"+title, content, link) + if openid := userOpenid(ctx, adminUserId); openid != "" { + _ = SendProjectRemind(ctx, ProjectRemindSendReq{ + Openid: openid, Title: title, Content: content, Page: link, + }) + } + } + + if _, err = g.Model(consts.TableServiceProjects).Where("id", r["id"].Int64()).Data(g.Map{ + "last_maintain_remind_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + g.Log().Warningf(ctx, "[maintain-remind] 记录提醒时间失败 projectId=%d: %v", r["id"].Int64(), err) + } + g.Log().Infof(ctx, "[maintain-remind] 已提醒 projectId=%d daysLeft=%d", r["id"].Int64(), daysLeft) + } +} + +// maintainDaysLeft 距维护费到期天数(向上取整;已逾期为负数)。 +func maintainDaysLeft(now, paidUntil *gtime.Time) int { + hours := paidUntil.Sub(now).Hours() + if hours <= 0 { + return -int(-hours/24) - 1 + } + d := int(hours / 24) + if hours-float64(d*24) > 0 { + d++ + } + return d +} + +// userOpenid 查用户 openid(站内信收件人对应的订阅消息送达对象)。 +func userOpenid(ctx context.Context, userId int64) string { + if userId <= 0 { + return "" + } + u, err := g.Model(consts.TableUsers).Where("id", userId).One() + if err != nil || u.IsEmpty() { + return "" + } + return u["openid"].String() +} diff --git a/internal/logic/quota.go b/internal/logic/quota.go index fd269a2..34ff9f8 100644 --- a/internal/logic/quota.go +++ b/internal/logic/quota.go @@ -89,6 +89,11 @@ func settingSave(ctx context.Context, key, value, remark string) error { return nil } +// SettingSave 导出的配置写入入口(后台系统配置等模块使用;写入后缓存立即失效) +func SettingSave(ctx context.Context, key, value, remark string) error { + return settingSave(ctx, key, value, remark) +} + // GlobalQuotaPeriodDays 全局默认额度周期(天) func GlobalQuotaPeriodDays(ctx context.Context) int { raw := SettingValue(ctx, consts.SettingQuotaPeriodDays) diff --git a/internal/logic/seed.go b/internal/logic/seed.go index 363a3d1..bdbb2a6 100644 --- a/internal/logic/seed.go +++ b/internal/logic/seed.go @@ -3,6 +3,7 @@ package logic import ( "context" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "golang.org/x/crypto/bcrypt" @@ -39,6 +40,115 @@ func Seed(ctx context.Context) { if err := seedRows(ctx, consts.TableSettings, "k", seedSettings()); err != nil { g.Log().Warning(ctx, "[seed] settings:", err) } + // 软件开发服务介绍页默认模块:表为空时补一套默认排版(后台可自由增删改) + if err := seedServiceBlocks(ctx); err != nil { + g.Log().Warning(ctx, "[seed] service_blocks:", err) + } + // 开发项目四期:维护模板 / 付款分期模板(后台「模板设置」可改,只做缺则补) + if err := seedRows(ctx, consts.TableMaintainPlans, "name", seedMaintainPlans()); err != nil { + g.Log().Warning(ctx, "[seed] maintain_plans:", err) + } + if err := seedRows(ctx, consts.TablePayPlans, "name", seedPayPlans()); err != nil { + g.Log().Warning(ctx, "[seed] pay_plans:", err) + } + if err := seedRows(ctx, consts.TableSettings, "k", seedProjectFlowSettings()); err != nil { + g.Log().Warning(ctx, "[seed] project_flow_settings:", err) + } +} + +// seedMaintainPlans 维护模板种子。 +func seedMaintainPlans() []g.Map { + return []g.Map{ + {"name": "一年免费维护", "free_years": 1, "yearly_fee_cents": 50000, + "source_only": 0, "sort": 1, "is_enabled": 1, + "remark": "交付后 1 年内免费维护,后续 ¥500/年"}, + {"name": "按年收费维护", "free_years": 0, "yearly_fee_cents": 50000, + "source_only": 0, "sort": 2, "is_enabled": 1, + "remark": "无免费期,维护服务 ¥500/年(验收后即起算)"}, + {"name": "仅交付源码", "free_years": 0, "yearly_fee_cents": 0, + "source_only": 1, "sort": 3, "is_enabled": 1, + "remark": "只交付源码,无售后维护"}, + } +} + +// seedPayPlans 付款分期模板种子(schedule JSON 由后端契约约定 trigger 节点)。 +func seedPayPlans() []g.Map { + return []g.Map{ + {"name": "两期 · 上线前结清", "sort": 1, "is_enabled": 1, + "schedule": `[{"seq":1,"percent":50,"trigger":"start"},{"seq":2,"percent":50,"trigger":"online_before"}]`, + "remark": "定金 50% 开工前,尾款 50% 上线前"}, + {"name": "两期 · 尾款上线后", "sort": 2, "is_enabled": 1, + "schedule": `[{"seq":1,"percent":50,"trigger":"start"},{"seq":2,"percent":50,"trigger":"online_after"}]`, + "remark": "定金 50% 开工前,尾款 50% 上线验收后"}, + {"name": "三期 · 中期演示续作", "sort": 3, "is_enabled": 1, + "schedule": `[{"seq":1,"percent":30,"trigger":"start"},{"seq":2,"percent":40,"trigger":"mid_demo"},{"seq":3,"percent":30,"trigger":"online_before"}]`, + "remark": "定金 30%,中期演示确认后续作 40%,上线前 30%"}, + } +} + +// seedProjectFlowSettings 开发项目流程设置键(后台「开发项目 → 模板设置」可改)。 +func seedProjectFlowSettings() []g.Map { + return []g.Map{ + {"k": "service.maintain.remindDays", "v": "15", "remark": "维护费到期前提醒天数(隔 3 天一发,最后 3 天每天发)"}, + {"k": "service.maintain.adminNotifyUserId", "v": "", "remark": "维护费提醒同时通知的小程序用户 id(超管自己绑定的账号,空=只进管理端横幅)"}, + } +} + +// seedServiceBlocks 服务介绍页默认模块(仅当表为空时写入一次,之后完全以后台编辑为准)。 +func seedServiceBlocks(ctx context.Context) error { + count, err := g.Model(consts.TableServiceBlocks).Count() + if err != nil { + return err + } + if count > 0 { + return nil + } + rows := []g.Map{ + { + "kind": consts.ServiceBlockHero, "title": "从想法到上线\n让代码**落地**为产品", + "subtitle": "定制开发 · 交付源码 · 免费咨询", "content": "", + "sort": 1, "is_enabled": 1, + }, + { + "kind": consts.ServiceBlockFlow, "title": "服务流程", + "content": "需求沟通|澄清目标与边界\n方案报价|评估周期与成本\n开发交付|敏捷迭代源码可交接\n售后保障|上线后持续维护", + "sort": 2, "is_enabled": 1, + }, + { + "kind": consts.ServiceBlockChecklist, "title": "标准交付清单", + "content": "需求文档\n原型与 UI 设计稿\n前后端源码\n接口与部署文档\n测试与验收记录", + "sort": 3, "is_enabled": 1, + }, + { + "kind": consts.ServiceBlockPrice, "title": "服务与价格", + "content": "官网展示型|官网搭建、页面设计、内容发布|3万起\n业务系统型|业务系统、流程管理、数据协同|8万起\n平台产品型|平台产品、权限体系、会员运营|面议", + "sort": 4, "is_enabled": 1, + }, + { + "kind": consts.ServiceBlockWorks, "title": "作品案例", + "subtitle": "真实交付案例,点开可查看详情与参考报价", + "sort": 5, "is_enabled": 1, + }, + { + "kind": consts.ServiceBlockDeveloper, "title": "8 年全栈开发经验 / 独立交付 60+ 项目", + "subtitle": "技术栈:Vue / React / 小程序 / Java / Python / 云部署", + "content": "", + "sort": 6, "is_enabled": 1, + }, + { + "kind": consts.ServiceBlockGoods, "title": "查看全部服务", + "subtitle": "更多服务项与参考报价", + "sort": 7, "is_enabled": 1, + }, + } + for _, row := range rows { + row["created_at"] = gdb.Raw("NOW()") + row["updated_at"] = gdb.Raw("NOW()") + if _, err := g.Model(consts.TableServiceBlocks).Data(row).Insert(); err != nil { + return err + } + } + return nil } // memberPlanSyncFields 每次启动以代码为准覆盖的列。 @@ -187,6 +297,9 @@ func seedSettings() []g.Map { {"k": consts.SettingFreebiePriceCents, "v": "1", "remark": "免单道具价格(分)"}, {"k": consts.SettingPromoFailLimitPerMin, "v": "6", "remark": "同一用户 1 分钟内优惠码错误次数上限"}, {"k": consts.SettingCouponLockTtlSeconds, "v": "900", "remark": "免单券下单占用自动释放时长(秒)"}, + // ===== 站点(迭代-2026-09-29)===== + // 图片域名不落库:库内只存相对路径,拼接前缀在此配置;留空 = 按请求 Host 现拼(本地联调零配置)。 + {"k": consts.SettingSiteBaseUrl, "v": "", "remark": "图片等静态资源的对外拼接前缀(如 https://api.example.com),留空=按请求域名动态拼接"}, } } diff --git a/internal/logic/service.go b/internal/logic/service.go new file mode 100644 index 0000000..e66ec4a --- /dev/null +++ b/internal/logic/service.go @@ -0,0 +1,543 @@ +package logic + +import ( + "context" + "encoding/json" + "strconv" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 软件开发服务二期(迭代-2026-09-30):介绍页模块 / 作品案例 / 报价申请 +// +// 约定与 goods 一致: +// - 图片库内存相对路径(/static/...),输出 absoluteURL 拼接(域名不落库); +// - content 存 Markdown 原文,C 端解析渲染,后端不做 HTML 转换; +// - 软删除靠 deleted_at(GoFrame 自动过滤); +// - TZ-01:datetime 写库一律 gdb.Raw("NOW()")。 +// ============================================================================ + +// serviceImagesDecode JSON 数组字符串 → 相对路径数组(容错:空/脏串返回空切片) +func serviceImagesDecode(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var out []string + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil + } + clean := make([]string, 0, len(out)) + for _, p := range out { + if p = strings.TrimSpace(p); p != "" { + clean = append(clean, p) + } + } + return clean +} + +// serviceImagesEncode 相对路径数组 → JSON 字符串(剔除空项) +func serviceImagesEncode(paths []string) string { + clean := make([]string, 0, len(paths)) + for _, p := range paths { + if p = strings.TrimSpace(p); p != "" { + clean = append(clean, p) + } + } + if len(clean) == 0 { + return "" + } + b, err := json.Marshal(clean) + if err != nil { + return "" + } + return string(b) +} + +// centsYuanText 分 → 元展示串(整数元不带小数;0 返回空串) +func centsYuanText(cents int64) string { + if cents <= 0 { + return "" + } + s := strconv.FormatFloat(float64(cents)/100, 'f', 2, 64) + s = strings.TrimRight(strings.TrimRight(s, "0"), ".") + if s == "" { + return "0" + } + return s +} + +// ============================================================================ +// C 端:介绍页模块 +// ============================================================================ + +// ServicePage 介绍页模块(仅启用,sort 升序) +func ServicePage(ctx context.Context) (*userv1.ServicePageRes, error) { + records, err := g.Model(consts.TableServiceBlocks). + Where("is_enabled", 1). + Order("sort asc, id asc"). + All() + if err != nil { + return nil, err + } + list := make([]userv1.ServiceBlockOut, 0, len(records)) + for _, r := range records { + images := serviceImagesDecode(r["images"].String()) + urls := make([]string, 0, len(images)) + for _, p := range images { + urls = append(urls, absoluteURL(ctx, p)) + } + list = append(list, userv1.ServiceBlockOut{ + Id: r["id"].Int64(), + Kind: r["kind"].String(), + Title: r["title"].String(), + Subtitle: r["subtitle"].String(), + ImageUrl: absoluteURL(ctx, r["image_path"].String()), + Images: urls, + Content: r["content"].String(), + }) + } + return &userv1.ServicePageRes{List: list}, nil +} + +// ============================================================================ +// C 端:作品案例 +// ============================================================================ + +// serviceWorkItemOf 列表行 → C 端作品输出 +func serviceWorkItemOf(ctx context.Context, r gdb.Record) userv1.WorkItem { + return userv1.WorkItem{ + Id: r["id"].Int64(), + Category: r["category"].Int(), + CategoryName: consts.GoodsCategoryNameOf(r["category"].Int()), + Title: r["title"].String(), + Summary: r["summary"].String(), + CoverUrl: absoluteURL(ctx, r["cover_path"].String()), + PriceCents: r["price_cents"].Int64(), + PriceNote: r["price_note"].String(), + } +} + +// ServiceWorks 作品列表(仅上架;sort 升序、id 降序) +func ServiceWorks(ctx context.Context, req *userv1.WorksListReq) (*userv1.WorksListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableServiceWorks).Where("is_enabled", 1) + if req.Category > 0 { + m = m.Where("category", req.Category) + } + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := m.Page(page, pageSize).Order("sort asc, id desc").All() + if err != nil { + return nil, err + } + list := make([]userv1.WorkItem, 0, len(records)) + for _, r := range records { + list = append(list, serviceWorkItemOf(ctx, r)) + } + return &userv1.WorksListRes{List: list, Total: total}, nil +} + +// ServiceWorkDetail 作品详情(仅上架;下架/不存在同义报错) +func ServiceWorkDetail(ctx context.Context, id int64) (*userv1.WorkDetailRes, error) { + r, err := g.Model(consts.TableServiceWorks).Where("id", id).Where("is_enabled", 1).One() + if err != nil { + return nil, err + } + if r.IsEmpty() { + return nil, gerror.New("作品不存在或已下架") + } + images := serviceImagesDecode(r["images"].String()) + urls := make([]string, 0, len(images)) + for _, p := range images { + urls = append(urls, absoluteURL(ctx, p)) + } + return &userv1.WorkDetailRes{ + WorkItem: serviceWorkItemOf(ctx, r), + Images: urls, + ContentMd: r["content_md"].String(), + UpdatedAt: timeStr(r["updated_at"].GTime()), + }, nil +} + +// ============================================================================ +// C 端:报价申请 +// ============================================================================ + +// ServiceQuoteSubmit 提交报价申请(需求 → 落库 + 站内信回执)。 +// 联系方式/需求做长度裁剪;关联作品名存快照(作品后续改名/下架不影响申请单)。 +func ServiceQuoteSubmit(ctx context.Context, req *userv1.QuoteSubmitReq) (*userv1.QuoteSubmitRes, error) { + contact := strings.TrimSpace(req.Contact) + requirement := strings.TrimSpace(req.Requirement) + if contact == "" { + return nil, gerror.New("请填写联系方式") + } + if requirement == "" { + return nil, gerror.New("请描述您的需求") + } + if len(contact) > 64 { + contact = contact[:64] + } + if len(requirement) > 1000 { + requirement = requirement[:1000] + } + budget := strings.TrimSpace(req.Budget) + if len(budget) > 64 { + budget = budget[:64] + } + + workId := req.WorkId + workTitle := "" + if workId > 0 { + wr, err := g.Model(consts.TableServiceWorks).Where("id", workId).One() + if err != nil { + return nil, err + } + // 作品不存在/已下架时不阻断:保留申请、标题留空(避免用户已填的需求丢失) + if !wr.IsEmpty() { + workTitle = wr["title"].String() + } else { + workId = 0 + } + } + + userId := CtxUserId(ctx) + res, err := g.Model(consts.TableServiceQuotes).Data(g.Map{ + "user_id": userId, + "work_id": workId, + "work_title": workTitle, + "contact": contact, + "requirement": requirement, + "budget": budget, + "status": consts.QuoteStatusPending, + "created_at": gdb.Raw("NOW()"), + "updated_at": gdb.Raw("NOW()"), + }).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + + PushMessage(ctx, userId, consts.MsgTypeQuoteSubmitted, + "报价申请已提交", + "我们已收到你的开发需求,会尽快处理并通过站内信告知报价结果。", + "/pkg-service/quotes") + return &userv1.QuoteSubmitRes{Id: id}, nil +} + +// ServiceQuoteMine 我的报价申请(最近 N 条) +func ServiceQuoteMine(ctx context.Context, limit int) (*userv1.QuoteMineRes, error) { + if limit <= 0 || limit > 50 { + limit = 20 + } + records, err := g.Model(consts.TableServiceQuotes). + Where("user_id", CtxUserId(ctx)). + OrderDesc("id"). + Limit(limit). + All() + if err != nil { + return nil, err + } + list := make([]userv1.QuoteMineItem, 0, len(records)) + for _, r := range records { + list = append(list, userv1.QuoteMineItem{ + Id: r["id"].Int64(), + WorkId: r["work_id"].Int64(), + WorkTitle: r["work_title"].String(), + Requirement: r["requirement"].String(), + Budget: r["budget"].String(), + Status: r["status"].Int(), + StatusText: consts.QuoteStatusNameOf(r["status"].Int()), + QuoteCents: r["quote_cents"].Int64(), + QuoteNote: r["quote_note"].String(), + HandledAt: timeStr(r["handled_at"].GTime()), + CreatedAt: timeStr(r["created_at"].GTime()), + }) + } + return &userv1.QuoteMineRes{List: list}, nil +} + +// ============================================================================ +// 管理端:介绍页模块 CRUD +// ============================================================================ + +// AdminServiceBlockList 模块列表(含停用;仅网站/软件开发相关管理页使用) +func AdminServiceBlockList(ctx context.Context) (*adminv1.AdminServiceBlockListRes, error) { + records, err := g.Model(consts.TableServiceBlocks). + Order("sort asc, id asc"). + All() + if err != nil { + return nil, err + } + list := make([]adminv1.AdminServiceBlockItem, 0, len(records)) + for _, r := range records { + images := serviceImagesDecode(r["images"].String()) + urls := make([]string, 0, len(images)) + for _, p := range images { + urls = append(urls, absoluteURL(ctx, p)) + } + list = append(list, adminv1.AdminServiceBlockItem{ + Id: r["id"].Int64(), + Kind: r["kind"].String(), + Title: r["title"].String(), + Subtitle: r["subtitle"].String(), + ImagePath: r["image_path"].String(), + ImageUrl: absoluteURL(ctx, r["image_path"].String()), + Images: images, + ImageUrls: urls, + Content: r["content"].String(), + Sort: r["sort"].Int(), + IsEnabled: r["is_enabled"].Int(), + UpdatedAt: timeStr(r["updated_at"].GTime()), + }) + } + return &adminv1.AdminServiceBlockListRes{List: list}, nil +} + +// AdminServiceBlockSave 新增/编辑介绍页模块。 +func AdminServiceBlockSave(ctx context.Context, req *adminv1.AdminServiceBlockSaveReq) (*adminv1.AdminServiceBlockSaveRes, error) { + kind := strings.TrimSpace(req.Kind) + switch kind { + case consts.ServiceBlockHero, consts.ServiceBlockRich, consts.ServiceBlockImageText, + consts.ServiceBlockImageList, consts.ServiceBlockFlow, consts.ServiceBlockChecklist, + consts.ServiceBlockPrice, consts.ServiceBlockDeveloper, consts.ServiceBlockWorks, + consts.ServiceBlockGoods: + default: + return nil, gerror.New("模块类型不合法") + } + data := g.Map{ + "kind": kind, + "title": strings.TrimSpace(req.Title), + "subtitle": strings.TrimSpace(req.Subtitle), + "image_path": normalizeToRelativePath(req.ImagePath), + "images": serviceImagesEncode(req.Images), + "content": req.Content, + "sort": req.Sort, + "is_enabled": req.IsEnabled, + } + if req.Id > 0 { + exist, err := g.Model(consts.TableServiceBlocks).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("模块不存在") + } + data["updated_at"] = gdb.Raw("NOW()") + if _, err = g.Model(consts.TableServiceBlocks).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "service.block.save", + TargetType: "service_block", + TargetId: auditId(req.Id), + Before: g.Map{"kind": exist["kind"].String(), "title": exist["title"].String()}, + After: g.Map{"kind": kind, "title": data["title"]}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminServiceBlockSaveRes{Id: req.Id}, nil + } + data["created_at"] = gdb.Raw("NOW()") + data["updated_at"] = gdb.Raw("NOW()") + res, err := g.Model(consts.TableServiceBlocks).Data(data).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + WriteAudit(ctx, AuditEntry{ + Action: "service.block.save", + TargetType: "service_block", + TargetId: auditId(id), + After: g.Map{"kind": kind, "title": data["title"]}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminServiceBlockSaveRes{Id: id}, nil +} + +// AdminServiceBlockDelete 删除介绍页模块(硬删:模块为纯展示配置,无引用) +func AdminServiceBlockDelete(ctx context.Context, id int64) (*adminv1.AdminServiceBlockDeleteRes, error) { + exist, err := g.Model(consts.TableServiceBlocks).Where("id", id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("模块不存在") + } + if _, err = g.Model(consts.TableServiceBlocks).Where("id", id).Delete(); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "service.block.delete", + TargetType: "service_block", + TargetId: auditId(id), + Before: g.Map{"kind": exist["kind"].String(), "title": exist["title"].String()}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminServiceBlockDeleteRes{}, nil +} + +// ============================================================================ +// 管理端:作品案例 CRUD +// ============================================================================ + +// AdminServiceWorkList 作品列表(含下架) +func AdminServiceWorkList(ctx context.Context, req *adminv1.AdminServiceWorkListReq) (*adminv1.AdminServiceWorkListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableServiceWorks) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + m = m.Where("title LIKE ?", "%"+kw+"%") + } + if req.Category > 0 { + m = m.Where("category", req.Category) + } + if req.IsEnabled != nil { + m = m.Where("is_enabled", *req.IsEnabled) + } + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := m.Page(page, pageSize).Order("sort asc, id desc").All() + if err != nil { + return nil, err + } + list := make([]adminv1.AdminServiceWorkItem, 0, len(records)) + for _, r := range records { + list = append(list, adminv1.AdminServiceWorkItem{ + Id: r["id"].Int64(), + Category: r["category"].Int(), + CategoryName: consts.GoodsCategoryNameOf(r["category"].Int()), + Title: r["title"].String(), + Summary: r["summary"].String(), + CoverPath: r["cover_path"].String(), + CoverUrl: absoluteURL(ctx, r["cover_path"].String()), + PriceCents: r["price_cents"].Int64(), + PriceNote: r["price_note"].String(), + Sort: r["sort"].Int(), + IsEnabled: r["is_enabled"].Int(), + CreatedAt: timeStr(r["created_at"].GTime()), + }) + } + return &adminv1.AdminServiceWorkListRes{List: list, Total: total}, nil +} + +// AdminServiceWorkDetail 作品详情(编辑回填) +func AdminServiceWorkDetail(ctx context.Context, id int64) (*adminv1.AdminServiceWorkDetailRes, error) { + r, err := g.Model(consts.TableServiceWorks).Where("id", id).One() + if err != nil { + return nil, err + } + if r.IsEmpty() { + return nil, gerror.New("作品不存在") + } + return &adminv1.AdminServiceWorkDetailRes{ + Id: r["id"].Int64(), + Category: r["category"].Int(), + Title: r["title"].String(), + Summary: r["summary"].String(), + ContentMd: r["content_md"].String(), + CoverPath: r["cover_path"].String(), + CoverUrl: absoluteURL(ctx, r["cover_path"].String()), + Images: serviceImagesDecode(r["images"].String()), + PriceCents: r["price_cents"].Int64(), + PriceNote: r["price_note"].String(), + Sort: r["sort"].Int(), + IsEnabled: r["is_enabled"].Int(), + }, nil +} + +// AdminServiceWorkSave 新增/编辑作品。 +func AdminServiceWorkSave(ctx context.Context, req *adminv1.AdminServiceWorkSaveReq) (*adminv1.AdminServiceWorkSaveRes, error) { + title := strings.TrimSpace(req.Title) + if title == "" { + return nil, gerror.New("请填写作品名称") + } + images := req.Images + for i := range images { + images[i] = normalizeToRelativePath(images[i]) + } + data := g.Map{ + "category": req.Category, + "title": title, + "summary": strings.TrimSpace(req.Summary), + "content_md": req.ContentMd, + "cover_path": normalizeToRelativePath(req.CoverPath), + "images": serviceImagesEncode(images), + "price_cents": req.PriceCents, + "price_note": strings.TrimSpace(req.PriceNote), + "sort": req.Sort, + "is_enabled": req.IsEnabled, + } + if req.Id > 0 { + exist, err := g.Model(consts.TableServiceWorks).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("作品不存在") + } + data["updated_at"] = gdb.Raw("NOW()") + if _, err = g.Model(consts.TableServiceWorks).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "service.work.save", + TargetType: "service_work", + TargetId: auditId(req.Id), + Before: g.Map{"title": exist["title"].String(), "is_enabled": exist["is_enabled"].Int()}, + After: g.Map{"title": title, "is_enabled": req.IsEnabled}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminServiceWorkSaveRes{Id: req.Id}, nil + } + data["created_at"] = gdb.Raw("NOW()") + data["updated_at"] = gdb.Raw("NOW()") + res, err := g.Model(consts.TableServiceWorks).Data(data).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + WriteAudit(ctx, AuditEntry{ + Action: "service.work.save", + TargetType: "service_work", + TargetId: auditId(id), + After: g.Map{"title": title, "is_enabled": req.IsEnabled}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminServiceWorkSaveRes{Id: id}, nil +} + +// AdminServiceWorkDelete 软删除作品(报价单存标题快照,不受影响) +func AdminServiceWorkDelete(ctx context.Context, id int64) (*adminv1.AdminServiceWorkDeleteRes, error) { + exist, err := g.Model(consts.TableServiceWorks).Where("id", id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("作品不存在") + } + if _, err = g.Model(consts.TableServiceWorks).Where("id", id).Data(g.Map{ + "deleted_at": gdb.Raw("NOW()"), + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "service.work.delete", + TargetType: "service_work", + TargetId: auditId(id), + Before: g.Map{"title": exist["title"].String()}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminServiceWorkDeleteRes{}, nil +} diff --git a/internal/logic/setting_admin.go b/internal/logic/setting_admin.go new file mode 100644 index 0000000..f9a340d --- /dev/null +++ b/internal/logic/setting_admin.go @@ -0,0 +1,82 @@ +package logic + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + adminv1 "tool-api/api/admin/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 管理端系统配置(迭代-2026-09-29) +// +// 走 settings 表(约定 S9:配置一律走 settings 表,不写 config.yaml)。 +// 只开放白名单键:管理端可读可写的键集中在这里登记,避免把 settings 全表暴露。 +// ============================================================================ + +// adminSettingWhitelist 管理端可读写的配置键白名单 +var adminSettingWhitelist = []struct { + Key string + Remark string + // Normalize 保存前的归一化(可为 nil) + Normalize func(string) string +}{ + { + Key: consts.SettingSiteBaseUrl, + Remark: "图片等静态资源的对外拼接前缀(如 https://api.example.com),留空 = 按请求域名动态拼接", + Normalize: func(v string) string { + return strings.TrimRight(strings.TrimSpace(v), "/") + }, + }, + { + Key: consts.SettingHomeFeatureCovers, + Remark: "首页便捷功能卡背景图,JSON:{\"note\":\"/static/...\",\"event\":\"...\"}(key 对齐小程序 features.ts)", + Normalize: func(v string) string { + return strings.TrimSpace(v) + }, + }, +} + +// AdminSettingGet 读取白名单配置项(含当前值与说明) +func AdminSettingGet(ctx context.Context) (*adminv1.AdminSettingGetRes, error) { + res := &adminv1.AdminSettingGetRes{Items: make([]adminv1.AdminSettingItem, 0, len(adminSettingWhitelist))} + for _, def := range adminSettingWhitelist { + res.Items = append(res.Items, adminv1.AdminSettingItem{ + Key: def.Key, + Value: SettingValue(ctx, def.Key), + Remark: def.Remark, + }) + } + return res, nil +} + +// AdminSettingSave 保存一项配置(key 必须在白名单内;保存立即生效,SettingValue 缓存同步失效) +func AdminSettingSave(ctx context.Context, key, value string) (*adminv1.AdminSettingSaveRes, error) { + for _, def := range adminSettingWhitelist { + if def.Key != key { + continue + } + v := value + if def.Normalize != nil { + v = def.Normalize(v) + } + before := SettingValue(ctx, key) + if err := SettingSave(ctx, key, v, def.Remark); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "setting.save", + TargetType: "setting", + TargetId: key, + Before: g.Map{"v": before}, + After: g.Map{"v": v}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminSettingSaveRes{}, nil + } + return nil, gerror.New("不支持的配置项") +} diff --git a/internal/logic/upload.go b/internal/logic/upload.go index 197f7be..c8355da 100644 --- a/internal/logic/upload.go +++ b/internal/logic/upload.go @@ -7,22 +7,28 @@ import ( "io" "path/filepath" "strings" + "unicode/utf8" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" "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" v1 "tool-api/api/user/v1" "tool-api/internal/consts" ) // ============================================================================ -// 头像上传(N3 / §2.2) +// 头像上传(N3 / §2.2)+ 通用上传(封面/附件,迭代-2026-09-29) // -// 本机静态目录方案(U1 已裁定接受):落盘 /avatar//., -// 返回「浏览器可直接打开」的绝对 URL。 +// 本机静态目录方案(U1 已裁定接受):落盘 ///.。 +// +// 📌 图片域名不落库(迭代-2026-09-29 裁定):数据库只存相对路径(/static/...), +// 对外输出一律经 absoluteURL 用 settings 的 site.base_url 拼接前缀;未配置时 +// 回退为按请求 Host 现拼(本地联调零配置)。 // // 已知限制:单实例部署正常;多实例(水平扩容)下非落盘实例会 404 —— 上线前须换对象存储。 // @@ -39,6 +45,9 @@ const staticURLPrefix = "/static" // AvatarMaxBytes 头像体积上限(5MB)。 const AvatarMaxBytes = 5 * 1024 * 1024 +// AttachmentMaxBytes 通用附件体积上限(20MB)。 +const AttachmentMaxBytes = 20 * 1024 * 1024 + // avatarAllowedExt 允许的图片扩展名(小写,含点)。 var avatarAllowedExt = map[string]bool{ ".jpg": true, @@ -47,6 +56,14 @@ var avatarAllowedExt = map[string]bool{ ".webp": true, } +// attachmentAllowedExt 通用附件允许的扩展名(小写,含点)。 +var attachmentAllowedExt = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true, + ".pdf": true, ".zip": true, + ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, + ".txt": true, ".md": true, +} + // errUploadInvalid 4016:上传校验失败(体积 / 格式)。 func errUploadInvalid(msg string) error { return gerror.NewCode(gcode.New(consts.CodeUploadInvalid, "", nil), msg) @@ -85,24 +102,17 @@ func AvatarUploadFromRequest(ctx context.Context) (*v1.AvatarUploadRes, error) { // AvatarUpload 校验并落盘头像,返回「浏览器可直接打开」的绝对 URL。 // // 落盘名用内容 md5 → 同一张图重复上传天然去重,且文件名不含用户输入(无路径穿越风险)。 +// 同时登记附件表(scene=avatar,上传者=当前用户),供后台附件管理统一查看。 func AvatarUpload(ctx context.Context, fileName string, data []byte) (*v1.AvatarUploadRes, error) { if err := validateAvatarUpload(fileName, data); err != nil { return nil, err } - sum := md5.Sum(data) - name := hex.EncodeToString(sum[:]) + strings.ToLower(filepath.Ext(fileName)) - ym := gtime.Now().Format("Ym") - relDir := filepath.Join(StaticDir, "avatar", ym) - if err := gfile.Mkdir(relDir); err != nil { - g.Log().Errorf(ctx, "[upload] 创建头像目录失败 dir=%s err=%v", relDir, err) - return nil, errUploadFailed("头像上传失败,请重试") + relPath, err := storeStatic(ctx, "avatar", fileName, data) + if err != nil { + return nil, err } - if err := gfile.PutBytes(filepath.Join(relDir, name), data); err != nil { - g.Log().Errorf(ctx, "[upload] 写入头像文件失败 dir=%s name=%s err=%v", relDir, name, err) - return nil, errUploadFailed("头像上传失败,请重试") - } - urlPath := staticURLPrefix + "/avatar/" + ym + "/" + name - return &v1.AvatarUploadRes{AvatarUrl: absoluteStaticURL(ctx, urlPath)}, nil + recordAttachment(ctx, consts.AttachmentSceneAvatar, fileName, relPath, data, "user", CtxUserId(ctx)) + return &v1.AvatarUploadRes{AvatarUrl: absoluteURL(ctx, relPath)}, nil } // validateAvatarUpload 头像上传校验(纯函数,可单测):非空 + 体积 ≤5MB + 扩展名合法 + 内容确为图片。 @@ -146,11 +156,24 @@ func isImageBytes(b []byte) bool { } // absoluteStaticURL 拼成「浏览器可直接打开」的绝对 URL(://)。 -// 无请求上下文(如单测)时回退为相对路径。 +// 无请求上下文(如单测)时回退为相对路径。保留旧名兼容既有调用点,实现统一走 absoluteURL。 func absoluteStaticURL(ctx context.Context, urlPath string) string { + return absoluteURL(ctx, urlPath) +} + +// ============================================================================ +// 图片域名拼接(迭代-2026-09-29):库内只存相对路径,域名统一在这里出现 +// ============================================================================ + +// baseURL 静态资源对外拼接前缀:settings 的 site.base_url 优先(后台「系统配置」可改); +// 留空时按请求 Host 现拼(本地联调零配置)。 +func baseURL(ctx context.Context) string { + if v := strings.TrimSpace(SettingValue(ctx, consts.SettingSiteBaseUrl)); v != "" { + return strings.TrimRight(v, "/") + } req := g.RequestFromCtx(ctx) if req == nil || req.Host == "" { - return urlPath + return "" } scheme := "http" if req.TLS != nil { @@ -159,5 +182,231 @@ func absoluteStaticURL(ctx context.Context, urlPath string) string { if proto := strings.TrimSpace(req.Header.Get("X-Forwarded-Proto")); proto != "" { scheme = proto } - return scheme + "://" + req.Host + urlPath + return scheme + "://" + req.Host +} + +// absoluteURL 相对路径 → 对外绝对 URL;空值或已是绝对 URL 时原样返回。 +// base 取不到(无请求上下文且未配置 site.base_url)时返回相对路径本身(调用端自行拼接)。 +func absoluteURL(ctx context.Context, path string) string { + p := strings.TrimSpace(path) + if p == "" || strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") { + return p + } + base := baseURL(ctx) + if base == "" { + return p + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return base + p +} + +// normalizeToRelativePath 把前端回传的 URL 归一化为相对路径后入库(剥掉 scheme://host)。 +// 兼容存量:头像等历史数据存过绝对 URL,写入侧统一收敛;读出侧统一 absoluteURL 拼接。 +// 兜底:若串中含 /static/ 段(如被调用侧拼过部署根路径 / 带盘符的路径),截取最后一个 +// /static/ 起始段,保证库内永远是 staticURLPrefix 开头的形态。 +func normalizeToRelativePath(url string) string { + u := strings.TrimSpace(url) + if u == "" { + return "" + } + if i := strings.Index(u, "://"); i > 0 { + rest := u[i+3:] + if j := strings.Index(rest, "/"); j >= 0 { + u = rest[j:] + } else { + return "" + } + } + if i := strings.LastIndex(u, staticURLPrefix+"/"); i > 0 { + return u[i:] + } + return u +} + +// staticPathToDisk 相对 URL 路径(/static/...)→ 磁盘路径(StaticDir/...),供删除文件用。 +// ok=false 表示不是 /static 下的合法相对路径(含 .. 一律拒绝,防路径穿越)。 +func staticPathToDisk(urlPath string) (disk string, ok bool) { + if !strings.HasPrefix(urlPath, staticURLPrefix+"/") { + return "", false + } + rel := strings.TrimPrefix(urlPath, staticURLPrefix+"/") + if rel == "" || strings.Contains(rel, "..") { + return "", false + } + return filepath.Join(StaticDir, filepath.FromSlash(rel)), true +} + +// ============================================================================ +// 通用上传(管理端:服务封面 / 通用附件) +// ============================================================================ + +// AdminUploadFromRequest 处理管理端 multipart 上传:取 file 字段 → 按场景校验 → 落盘 → 登记 → 返回。 +// +// multipart 表单文件字段名固定为 `file`(与头像上传同一跨端契约);scene 为表单字段。 +func AdminUploadFromRequest(ctx context.Context, scene string) (*adminv1.AdminUploadRes, error) { + req := g.RequestFromCtx(ctx) + if req == nil { + return nil, errUploadFailed("上传失败,请重试") + } + file := req.GetUploadFile("file") + if file == nil { + return nil, errUploadInvalid("请选择要上传的文件") + } + f, err := file.Open() + if err != nil { + return nil, errUploadFailed("上传失败,请重试") + } + defer func() { _ = f.Close() }() + // 多读 1 字节用于判定「超过上限」,避免把超大文件全读进内存。 + data, err := io.ReadAll(io.LimitReader(f, AttachmentMaxBytes+1)) + if err != nil { + return nil, errUploadFailed("上传失败,请重试") + } + return AdminUpload(ctx, scene, file.Filename, data) +} + +// AdminUpload 按场景校验并落盘,登记附件表,返回相对路径 + 现拼绝对 URL。 +// +// 封面(cover)仅图片 ≤5MB;通用附件(attachment)白名单格式 ≤20MB。 +// 场景决定落盘子目录与附件表 scene 字段(avatar 场景走用户端头像接口,不经此入口)。 +func AdminUpload(ctx context.Context, scene, fileName string, data []byte) (*adminv1.AdminUploadRes, error) { + dirName := "" + switch scene { + case consts.AttachmentSceneCover: + if err := validateImageUpload(fileName, data, "封面"); err != nil { + return nil, err + } + dirName = "cover" + case consts.AttachmentSceneAttachment: + if err := validateAttachmentUpload(fileName, data); err != nil { + return nil, err + } + dirName = "attachment" + default: + return nil, errUploadInvalid("不支持的上传场景") + } + relPath, err := storeStatic(ctx, dirName, fileName, data) + if err != nil { + return nil, err + } + id := recordAttachment(ctx, scene, fileName, relPath, data, "admin", CtxAdminId(ctx)) + return &adminv1.AdminUploadRes{ + Id: id, + Url: absoluteURL(ctx, relPath), + Path: relPath, + FileName: fileName, + Ext: strings.ToLower(filepath.Ext(fileName)), + SizeBytes: int64(len(data)), + }, nil +} + +// storeStatic 通用落盘:///.,返回相对 URL 路径。 +func storeStatic(ctx context.Context, dirName, fileName string, data []byte) (string, error) { + if len(data) == 0 { + return "", errUploadInvalid("上传内容为空") + } + sum := md5.Sum(data) + name := hex.EncodeToString(sum[:]) + strings.ToLower(filepath.Ext(fileName)) + ym := gtime.Now().Format("Ym") + relDir := filepath.Join(StaticDir, dirName, ym) + if err := gfile.Mkdir(relDir); err != nil { + g.Log().Errorf(ctx, "[upload] 创建目录失败 dir=%s err=%v", relDir, err) + return "", errUploadFailed("上传失败,请重试") + } + if err := gfile.PutBytes(filepath.Join(relDir, name), data); err != nil { + g.Log().Errorf(ctx, "[upload] 写入文件失败 dir=%s name=%s err=%v", relDir, name, err) + return "", errUploadFailed("上传失败,请重试") + } + return staticURLPrefix + "/" + dirName + "/" + ym + "/" + name, nil +} + +// recordAttachment 登记附件表(同 file_path 已有未删记录则跳过 —— md5 落盘天然去重)。 +// 失败仅告警不阻断上传主流程;返回记录 id(跳过/失败返回 0)。 +// TZ-01:datetime 写库一律 gdb.Raw("NOW()")。 +func recordAttachment(ctx context.Context, scene, fileName, relPath string, data []byte, uploaderType string, uploaderId int64) int64 { + sum := md5.Sum(data) + count, err := g.Model(consts.TableAttachments).Where("file_path", relPath).WhereNull("deleted_at").Count() + if err != nil { + g.Log().Warningf(ctx, "[upload] 附件登记查重失败 path=%s err=%v", relPath, err) + return 0 + } + if count > 0 { + return 0 + } + res, err := g.Model(consts.TableAttachments).Data(g.Map{ + "scene": scene, + // 客户端文件名可能带非法字节(老客户端 GBK 等):消毒为合法 UTF-8, + // 避免「文件已落盘但登记失败」把附件管理变成盲区 + "file_name": strings.ToValidUTF8(fileName, string(utf8.RuneError)), + "file_path": relPath, + "ext": strings.ToLower(filepath.Ext(fileName)), + "size_bytes": len(data), + "md5": hex.EncodeToString(sum[:]), + "uploader_id": uploaderId, + "uploader_type": uploaderType, + "created_at": gdb.Raw("NOW()"), + }).Insert() + if err != nil { + g.Log().Warningf(ctx, "[upload] 附件登记失败 path=%s err=%v", relPath, err) + return 0 + } + id, _ := res.LastInsertId() + return id +} + +// validateImageUpload 图片上传校验(封面等):非空 + 体积 ≤5MB + 扩展名合法 + 内容确为图片。 +func validateImageUpload(fileName string, data []byte, label string) error { + if len(data) == 0 { + return errUploadInvalid("上传内容为空") + } + if len(data) > AvatarMaxBytes { + return errUploadInvalid(label + "不能超过 5MB") + } + ext := strings.ToLower(filepath.Ext(fileName)) + if !avatarAllowedExt[ext] { + return errUploadInvalid("仅支持 jpg / png / webp 格式的图片") + } + if !isImageBytes(data) { + return errUploadInvalid("文件不是有效的图片") + } + return nil +} + +// validateAttachmentUpload 通用附件校验:非空 + 体积 ≤20MB + 扩展名白名单 + 抽查魔数。 +func validateAttachmentUpload(fileName string, data []byte) error { + if len(data) == 0 { + return errUploadInvalid("上传内容为空") + } + if len(data) > AttachmentMaxBytes { + return errUploadInvalid("附件不能超过 20MB") + } + ext := strings.ToLower(filepath.Ext(fileName)) + if !attachmentAllowedExt[ext] { + return errUploadInvalid("不支持的附件格式") + } + if !attachmentMagicOk(ext, data) { + return errUploadInvalid("文件内容与扩展名不符") + } + return nil +} + +// attachmentMagicOk 按扩展名抽查内容魔数(可校验的才校验,txt/md 等纯文本跳过)。 +func attachmentMagicOk(ext string, b []byte) bool { + switch ext { + case ".jpg", ".jpeg", ".png", ".webp": + return isImageBytes(b) + case ".gif": + return len(b) >= 6 && (string(b[0:3]) == "GIF") + case ".pdf": + return len(b) >= 5 && string(b[0:4]) == "%PDF" + case ".zip", ".docx", ".xlsx", ".pptx": + // Office 新格式(docx/xlsx/pptx)本质是 zip 容器,同用 PK 头 + return len(b) >= 4 && b[0] == 0x50 && b[1] == 0x4B + case ".doc", ".xls", ".ppt": + // OLE2 复合文档头 + return len(b) >= 8 && b[0] == 0xD0 && b[1] == 0xCF && b[2] == 0x11 && b[3] == 0xE0 + } + return true } diff --git a/internal/logic/user.go b/internal/logic/user.go index 1a3570c..fd4cabd 100644 --- a/internal/logic/user.go +++ b/internal/logic/user.go @@ -49,7 +49,7 @@ func GetUserInfo(ctx context.Context) (*v1.UserInfo, error) { if err != nil { return nil, err } - return &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: user.AvatarUrl}, nil + return &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: absoluteURL(ctx, user.AvatarUrl)}, nil } // wxSession jscode2session 返回:openid + session_key @@ -299,11 +299,12 @@ func ProfileUpdate(ctx context.Context, nickname, avatarUrl string) (*v1.Profile userId := CtxUserId(ctx) if _, err := g.Model(consts.TableUsers).Where("id", userId).Data(g.Map{ "nickname": nickname, - "avatar_url": avatarUrl, + "avatar_url": normalizeToRelativePath(avatarUrl), // 库内只存相对路径(图片域名不落库) }).Update(); err != nil { return nil, err } - return &v1.ProfileUpdateRes{Nickname: nickname, AvatarUrl: avatarUrl}, nil + // 返回值按现配 base 拼成绝对 URL,前端直接可用(与库内相对路径解耦) + return &v1.ProfileUpdateRes{Nickname: nickname, AvatarUrl: absoluteURL(ctx, normalizeToRelativePath(avatarUrl))}, nil } func workbenchKeysOf(ctx context.Context, userId int64) []string { @@ -344,7 +345,7 @@ func loginResult(ctx context.Context, user *entity.Users) (*v1.WxLoginRes, error } return &v1.WxLoginRes{ Token: token, - User: &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: user.AvatarUrl}, + User: &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: absoluteURL(ctx, user.AvatarUrl)}, Level: level, }, nil } diff --git a/internal/logic/user_detail.go b/internal/logic/user_detail.go index 17c81e8..e878568 100644 --- a/internal/logic/user_detail.go +++ b/internal/logic/user_detail.go @@ -71,7 +71,7 @@ func buildUserDetailBase(ctx context.Context, user *entity.Users) adminv1.UserDe base := adminv1.UserDetailBase{ Id: user.Id, Nickname: user.Nickname, - AvatarUrl: user.AvatarUrl, + AvatarUrl: absoluteURL(ctx, user.AvatarUrl), Openid: user.Openid, LevelKey: user.LevelKey, Status: user.Status, diff --git a/internal/logic/wxmsg.go b/internal/logic/wxmsg.go index f75fb01..39b49d9 100644 --- a/internal/logic/wxmsg.go +++ b/internal/logic/wxmsg.go @@ -99,3 +99,60 @@ func clampRunes(s string, max int) string { } return string(rs[:max]) } + +// ProjectRemindTemplateId 开发项目提醒模板 ID;未配置返回空串(降级为站内信)。 +func ProjectRemindTemplateId(ctx context.Context) string { + return g.Cfg().MustGet(ctx, "wx.subscribe.projectRemindTemplateId", "").String() +} + +// ProjectRemindSendReq 一条项目提醒的推送参数 +type ProjectRemindSendReq struct { + Openid string + Title string + Content string + Page string // 点击进入的小程序页面(如 pkg-service/project-detail?id=1) +} + +// SendProjectRemind 发送开发项目订阅提醒(维护费到期等)。 +// 与笔记提醒同一条底层通路;模板 ID 为空是合法降级路径(仅告警、正常返回)。 +// ⚠️ 字段键需与 MP 后台创建的模板一致:thing1 标题 / thing3 正文摘要。 +func SendProjectRemind(ctx context.Context, req ProjectRemindSendReq) error { + tplId := ProjectRemindTemplateId(ctx) + if tplId == "" { + g.Log().Warningf(ctx, "[project-remind] 未配置订阅消息模板 wx.subscribe.projectRemindTemplateId,降级为站内提醒") + return nil + } + if req.Openid == "" { + return gerror.New("用户 openid 为空,无法发送订阅消息") + } + token, err := wxAccessToken(ctx) + if err != nil { + return err + } + data := g.Map{ + "thing1": g.Map{"value": clampRunes(req.Title, 20)}, + "thing3": g.Map{"value": clampRunes(req.Content, 20)}, + } + body := g.Map{ + "touser": req.Openid, + "template_id": tplId, + "page": req.Page, + "data": data, + } + resp, err := g.Client().Post(ctx, xpayAPIBase+noteSubscribeSendPath+"?access_token="+token, body) + if err != nil { + return gerror.Newf("调用 subscribe/send 失败: %v", err) + } + defer resp.Close() + var out struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err = json.Unmarshal(resp.ReadAll(), &out); err != nil { + return gerror.Newf("解析 subscribe/send 响应失败: %v", err) + } + if out.ErrCode != 0 { + return gerror.Newf("订阅消息发送失败: %d %s", out.ErrCode, out.ErrMsg) + } + return nil +} diff --git a/manifest/static/attachment/202609/05b25b14bb98047766b9b946b5020162.png b/manifest/static/attachment/202609/05b25b14bb98047766b9b946b5020162.png new file mode 100644 index 0000000..b78718a Binary files /dev/null and b/manifest/static/attachment/202609/05b25b14bb98047766b9b946b5020162.png differ diff --git a/manifest/static/attachment/202609/f94b901f3e4e0ae383104e6d7ae48aab.txt b/manifest/static/attachment/202609/f94b901f3e4e0ae383104e6d7ae48aab.txt new file mode 100644 index 0000000..7387648 --- /dev/null +++ b/manifest/static/attachment/202609/f94b901f3e4e0ae383104e6d7ae48aab.txt @@ -0,0 +1 @@ +需求文档草稿 v1 diff --git a/manifest/static/cover/202609/05b25b14bb98047766b9b946b5020162.png b/manifest/static/cover/202609/05b25b14bb98047766b9b946b5020162.png new file mode 100644 index 0000000..b78718a Binary files /dev/null and b/manifest/static/cover/202609/05b25b14bb98047766b9b946b5020162.png differ diff --git a/manifest/static/cover/202609/150ebb05e2c26d639d775065da5663e8.jpg b/manifest/static/cover/202609/150ebb05e2c26d639d775065da5663e8.jpg new file mode 100644 index 0000000..f33ab93 Binary files /dev/null and b/manifest/static/cover/202609/150ebb05e2c26d639d775065da5663e8.jpg differ diff --git a/manifest/static/cover/202609/sticker_edge2edge.png b/manifest/static/cover/202609/sticker_edge2edge.png new file mode 100644 index 0000000..67a2689 Binary files /dev/null and b/manifest/static/cover/202609/sticker_edge2edge.png differ diff --git a/sql/software_service.sql b/sql/software_service.sql new file mode 100644 index 0000000..62a2757 --- /dev/null +++ b/sql/software_service.sql @@ -0,0 +1,47 @@ +-- 软件开发服务域 + 附件管理 + 站点配置(迭代-2026-09-29) +-- +-- 本文件仅作人工参考:启动时 logic.Migrate() 会幂等执行同样的 DDL(升级只需重启)。 +-- 手工执行亦可(CREATE TABLE IF NOT EXISTS 可重复运行)。 + +-- 服务商品(软件开发服务:网站/小程序/App/桌面四系列) +CREATE TABLE IF NOT EXISTS `goods` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `category` tinyint NOT NULL DEFAULT 1 COMMENT '分类 1网站开发 2小程序开发 3App开发 4桌面程序开发', + `title` varchar(128) NOT NULL DEFAULT '' COMMENT '服务名称', + `summary` varchar(500) NOT NULL DEFAULT '' COMMENT '简介(列表卡片展示)', + `content_md` mediumtext COMMENT '详细说明 Markdown 原文,小程序端解析渲染', + `price_cents` bigint NOT NULL DEFAULT 0 COMMENT '定价(分),0=面议', + `price_note` varchar(64) NOT NULL DEFAULT '' COMMENT '价格备注(如「起」「/项目」)', + `cover_path` varchar(255) NOT NULL DEFAULT '' COMMENT '封面相对路径(/static/...),库内不存域名', + `sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前', + `is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '上架状态 0下架 1上架', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除', + PRIMARY KEY (`id`), + KEY `idx_cat_sort` (`is_enabled`,`category`,`sort`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发服务商品'; + +-- 上传附件登记(头像/封面/通用附件统一落账,供后台附件管理) +CREATE TABLE IF NOT EXISTS `attachments` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `scene` varchar(32) NOT NULL DEFAULT '' COMMENT '上传场景 avatar/cover/attachment', + `file_name` varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名', + `file_path` varchar(255) NOT NULL DEFAULT '' COMMENT '相对路径(/static/...),库内不存域名', + `ext` varchar(16) NOT NULL DEFAULT '' COMMENT '扩展名(小写含点)', + `size_bytes` bigint NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', + `md5` char(32) NOT NULL DEFAULT '' COMMENT '内容 md5(落盘文件名)', + `uploader_id` bigint NOT NULL DEFAULT 0 COMMENT '上传者 id(管理端或用户)', + `uploader_type` varchar(16) NOT NULL DEFAULT 'admin' COMMENT '上传者类型 admin/user', + `created_at` datetime DEFAULT NULL COMMENT '上传日期', + `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除(记录删除,文件本体按引用检查)', + PRIMARY KEY (`id`), + KEY `idx_created` (`created_at`), + KEY `idx_path` (`file_path`), + KEY `idx_scene` (`scene`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传附件登记(上传日期/上传者/大小,供后台附件管理)'; + +-- 站点配置:图片等静态资源的对外拼接前缀(种子缺则补;留空 = 按请求域名动态拼接) +-- INSERT INTO `settings` (`k`, `v`, `remark`, `updated_at`) +-- VALUES ('site.base_url', '', '图片等静态资源的对外拼接前缀(如 https://api.example.com),留空=按请求域名动态拼接', NOW()) +-- ON DUPLICATE KEY UPDATE `k` = `k`;