修复个人中心问题
This commit is contained in:
@@ -103,9 +103,11 @@ func (c *AppAuthController) GetCurrentUser() {
|
||||
return
|
||||
}
|
||||
|
||||
// 令牌 user_id 是认证中心 identity_id,需换算成本租户的老 uid 再查老表
|
||||
uid := services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||||
var tenantUser models.SystemTenantUser
|
||||
err = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", claims.UserID).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&tenantUser)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,12 +2,14 @@ package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/pkg/passwordutil"
|
||||
"server/services"
|
||||
|
||||
@@ -28,6 +30,7 @@ type backendUserInfoDTO struct {
|
||||
Name *string `json:"name"`
|
||||
Phone *string `json:"phone"`
|
||||
Email *string `json:"email"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Sex uint8 `json:"sex"`
|
||||
Birth *string `json:"birth"`
|
||||
IsDefault int8 `json:"is_default"`
|
||||
@@ -94,6 +97,12 @@ func toBackendUserInfoDTO(u models.SystemTenantUser) backendUserInfoDTO {
|
||||
tenantCode = tenant.TenantCode
|
||||
}
|
||||
|
||||
// 头像取自认证中心身份(业务用户表 yz_system_tenant_user 不存头像)
|
||||
var avatar *string
|
||||
if a := services.GetIdentityAvatar(u.Uid); a != "" {
|
||||
avatar = &a
|
||||
}
|
||||
|
||||
return backendUserInfoDTO{
|
||||
ID: u.ID,
|
||||
Tid: u.Tid,
|
||||
@@ -103,6 +112,7 @@ func toBackendUserInfoDTO(u models.SystemTenantUser) backendUserInfoDTO {
|
||||
Name: u.Name,
|
||||
Phone: u.Phone,
|
||||
Email: u.Email,
|
||||
Avatar: avatar,
|
||||
Sex: u.Sex,
|
||||
Birth: formatBackendBirth(u.Birth),
|
||||
IsDefault: u.IsDefault,
|
||||
@@ -115,21 +125,43 @@ func toBackendUserInfoDTO(u models.SystemTenantUser) backendUserInfoDTO {
|
||||
}
|
||||
}
|
||||
|
||||
// getJWTUidTid 取当前登录者标识:
|
||||
// - uid:令牌里的 user_id,即认证中心 identity_id
|
||||
// - tid:令牌里的 tenant_id
|
||||
//
|
||||
// 键名历史上有两套写法(uid/tid 与 userId/tenantId),这里都兼容;
|
||||
// 中间件在 warn/off 模式下可能没有注入,故再兜底自行解析 Authorization 头,
|
||||
// 否则会静默拿到 0(曾导致按 identity_id 直查老表、显示到别人的资料)。
|
||||
func (c *BackendAdminUserController) getJWTUidTid() (uint64, uint64) {
|
||||
var uid uint64
|
||||
var tid uint64
|
||||
|
||||
data := c.Ctx.Input.Data()
|
||||
|
||||
if jwtUid := data["uid"]; jwtUid != nil {
|
||||
if v, ok := jwtUid.(uint64); ok {
|
||||
for _, key := range []string{"uid", "userId"} {
|
||||
if v, ok := data[key].(uint64); ok && v > 0 {
|
||||
uid = v
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"tid", "tenantId"} {
|
||||
if v, ok := data[key].(uint64); ok && v > 0 {
|
||||
tid = v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if jwtTid := data["tid"]; jwtTid != nil {
|
||||
if v, ok := jwtTid.(uint64); ok {
|
||||
tid = v
|
||||
if uid == 0 || tid == 0 {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if parts := strings.SplitN(auth, " ", 2); len(parts) == 2 && parts[0] == "Bearer" {
|
||||
if claims, err := jwtutil.ParseToken(parts[1]); err == nil {
|
||||
if uid == 0 {
|
||||
uid = uint64(claims.UserID)
|
||||
}
|
||||
if tid == 0 {
|
||||
tid = uint64(claims.TenantId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,33 +181,44 @@ func (c *BackendAdminUserController) parseTenantUserPayload() (backendTenantUser
|
||||
return p, true
|
||||
}
|
||||
|
||||
// findBackendTenantUser 按「用户在认证中心的 identity_id」或「记录主键 id」查租户用户。
|
||||
//
|
||||
// 注意查询顺序:令牌/前端传来的用户标识是认证中心 identity_id,而
|
||||
// yz_system_tenant_user.uid 仍是老 uid(老表不在 uid 全量迁移范围内),
|
||||
// 因此必须先换算再按 uid 查;先按主键 id 查会把 identity_id 命中的是
|
||||
// 主键恰好相同的**别人**的记录(个人中心显示他人资料就是这么来的)。
|
||||
func findBackendTenantUser(idOrUid uint64, jwtTid uint64) (*models.SystemTenantUser, error) {
|
||||
var row models.SystemTenantUser
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("delete_time__isnull", true)
|
||||
|
||||
if jwtTid > 0 {
|
||||
qs = qs.Filter("tid", jwtTid)
|
||||
if idOrUid == 0 {
|
||||
return nil, errors.New("用户ID不能为空")
|
||||
}
|
||||
|
||||
err := qs.Filter("id", idOrUid).One(&row)
|
||||
if err == nil {
|
||||
base := func() orm.QuerySeter {
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("delete_time__isnull", true)
|
||||
if jwtTid > 0 {
|
||||
qs = qs.Filter("tid", jwtTid)
|
||||
}
|
||||
return qs
|
||||
}
|
||||
|
||||
// 1) 当作 identity_id:换算成本租户老 uid 后匹配
|
||||
var row models.SystemTenantUser
|
||||
if uid := services.ResolveTenantUID(jwtTid, idOrUid); uid > 0 {
|
||||
if err := base().Filter("uid", uid).One(&row); err == nil {
|
||||
return &row, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 当作记录主键 id(用户管理页传的就是 row.id)
|
||||
if err := base().Filter("id", idOrUid).One(&row); err == nil {
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
qs = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("delete_time__isnull", true)
|
||||
|
||||
if jwtTid > 0 {
|
||||
qs = qs.Filter("tid", jwtTid)
|
||||
}
|
||||
|
||||
err = qs.Filter("uid", idOrUid).One(&row)
|
||||
// 3) 兜底:本来就是老 uid 的场景
|
||||
err := base().Filter("uid", idOrUid).One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
@@ -355,7 +398,15 @@ func (c *BackendAdminUserController) AddUser() {
|
||||
p.Password = &hashed
|
||||
|
||||
if p.Uid == 0 {
|
||||
uid, err := generateTenantUID(p.Tid)
|
||||
// 统一走 uid:优先用认证中心 identity_id,与已全量迁移的业务表口径一致
|
||||
uid, err := ensureTenantUIDForNewUser(
|
||||
p.Tid,
|
||||
strOrEmpty(p.Account),
|
||||
strOrEmpty(p.Name),
|
||||
strOrEmpty(p.Phone),
|
||||
strOrEmpty(p.Email),
|
||||
strOrEmpty(p.Password),
|
||||
)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成用户ID失败"}
|
||||
_ = c.ServeJSON()
|
||||
|
||||
@@ -149,9 +149,12 @@ func (c *BackendAuthController) GetCurrentUser() {
|
||||
return
|
||||
}
|
||||
|
||||
// 令牌里的 user_id 是认证中心 identity_id,而老表 uid 不在 uid 迁移范围内,
|
||||
// 必须先换算成本租户的老 uid,否则会查不到(甚至按主键误命中他人记录)
|
||||
uid := services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||||
var tenantUser models.SystemTenantUser
|
||||
err = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", claims.UserID).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&tenantUser)
|
||||
if err != nil {
|
||||
@@ -179,17 +182,24 @@ func (c *BackendAuthController) GetCurrentUser() {
|
||||
tenantName = strings.TrimSpace(tenant.TenantName)
|
||||
}
|
||||
|
||||
// 头像:统一取认证中心身份头像(个人中心上传后写在这里,userinfo 同源)
|
||||
avatar := services.GetIdentityAvatar(tenantUser.Uid)
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"id": tenantUser.Uid,
|
||||
// id 统一为认证中心 identity_id,与 /auth/userinfo 保持一致,
|
||||
// 避免同一字段在不同来源(登录回跳 vs 本接口)含义不同
|
||||
"id": claims.UserID,
|
||||
// uid 为老表 yz_system_tenant_user.uid,仅少数按老 uid 存取的场景需要
|
||||
"uid": tenantUser.Uid,
|
||||
"account": account,
|
||||
"name": name,
|
||||
"tid": tenantUser.Tid,
|
||||
"tenant_name": tenantName,
|
||||
"rid": 0,
|
||||
"avatar": "",
|
||||
"avatar": avatar,
|
||||
"role_name": "",
|
||||
},
|
||||
})
|
||||
@@ -220,9 +230,11 @@ func (c *BackendAuthController) GetCurrentUserProfile() {
|
||||
return
|
||||
}
|
||||
|
||||
// 同上:令牌 user_id 是 identity_id,需换算成本租户的老 uid
|
||||
uid := services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||||
var tenantUser models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", claims.UserID).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&tenantUser); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "用户不存在"})
|
||||
|
||||
@@ -298,6 +298,93 @@ func (c *BackendCrmBusinessController) Update() {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID})
|
||||
}
|
||||
|
||||
// ChangeStage POST /backend/crm/business/:id/stage
|
||||
// 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单。
|
||||
// 仅更新阶段字段,不触碰其他业务字段;已转化并锁定的商机不允许再流转。
|
||||
func (c *BackendCrmBusinessController) ChangeStage() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p struct {
|
||||
Stage string `json:"stage"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
stage := strings.TrimSpace(p.Stage)
|
||||
if !validBusinessStage(stage) {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的商机阶段")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var biz models.TenantCrmBusiness
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&biz); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "商机未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
|
||||
return
|
||||
}
|
||||
if biz.Locked == 1 || biz.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能流转阶段")
|
||||
return
|
||||
}
|
||||
// 阶段未变化:直接幂等返回,不写日志
|
||||
if strings.TrimSpace(biz.Stage) == stage {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "stage": biz.Stage})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"stage": stage, "update_time": time.Now()}); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "阶段更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 2, biz.ID, "status",
|
||||
"商机阶段:"+businessStageName(biz.Stage)+" → "+businessStageName(stage), claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "stage": stage})
|
||||
}
|
||||
|
||||
// businessStageName 商机阶段文案(用于操作日志):1初步接洽 … 5赢单 / 6输单
|
||||
func businessStageName(s string) string {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "2":
|
||||
return "需求确认"
|
||||
case "3":
|
||||
return "方案报价"
|
||||
case "4":
|
||||
return "商务谈判"
|
||||
case "5":
|
||||
return "赢单"
|
||||
case "6":
|
||||
return "输单"
|
||||
default:
|
||||
return "初步接洽"
|
||||
}
|
||||
}
|
||||
|
||||
// validBusinessStage 商机阶段取值校验(1~6)
|
||||
func validBusinessStage(s string) bool {
|
||||
switch s {
|
||||
case "1", "2", "3", "4", "5", "6":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/crm/business/:id
|
||||
func (c *BackendCrmBusinessController) Delete() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
|
||||
@@ -92,9 +92,9 @@ func (c *BackendCrmClueController) List() {
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmClue
|
||||
if total > 0 {
|
||||
// 列表排列:跟进中(1) → 已转化(2) → 已废弃(3),同状态下按新→旧。
|
||||
// 状态值本身即为期望的展示顺序,按 status 升序即可(排序在数据库侧完成,翻页后整体顺序一致)。
|
||||
_, _ = qs.OrderBy("status", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
// 列表排列:按 id 倒序,最新创建的线索排在最前。
|
||||
// 排序在数据库侧完成,翻页后整体顺序保持一致。
|
||||
_, _ = qs.OrderBy("-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list)
|
||||
}
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
@@ -293,6 +293,66 @@ func (c *BackendCrmClueController) Update() {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID})
|
||||
}
|
||||
|
||||
// ChangeStatus POST /backend/crm/clue/:id/status
|
||||
// 线索状态流转:1=跟进中 / 3=已废弃。
|
||||
// 「已转化(2)」不开放本接口——必须通过 Convert 完成(会落地正式客户并锁定线索),
|
||||
// 否则会出现「状态已转化但客户未落地」的脏数据。
|
||||
func (c *BackendCrmClueController) ChangeStatus() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
if err != nil {
|
||||
pipelineErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的ID")
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p struct {
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.Status != 1 && p.Status != 3 {
|
||||
pipelineErr(&c.Controller, 400, 400, "仅支持流转为「跟进中 / 已废弃」,转为已转化请使用「转化商机」")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var clue models.TenantCrmClue
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Filter("delete_time__isnull", true).One(&clue); err != nil {
|
||||
pipelineErr(&c.Controller, 404, 404, "线索未找到")
|
||||
return
|
||||
}
|
||||
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
|
||||
if !canAccessCrmRecord(claims, clue.OwnerUserID, clue.CreateUserID) {
|
||||
pipelineErr(&c.Controller, 403, 403, "无权操作该线索数据")
|
||||
return
|
||||
}
|
||||
if clue.Locked == 1 || clue.Status == 2 {
|
||||
pipelineErr(&c.Controller, 400, 400, "线索已转化并锁定,不能流转状态")
|
||||
return
|
||||
}
|
||||
// 状态未变化:直接幂等返回,不写日志
|
||||
if clue.Status == p.Status {
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID, "status": clue.Status})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmClue)).
|
||||
Filter("id", id).Filter("tenant_id", tenantID).
|
||||
Update(map[string]interface{}{"status": p.Status, "update_time": time.Now()}); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 1, clue.ID, "status",
|
||||
fmt.Sprintf("线索状态:%s → %s", clueStatusName(clue.Status), clueStatusName(p.Status)), claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": clue.ID, "status": p.Status})
|
||||
}
|
||||
|
||||
// clueStatusName 线索状态文案(用于操作日志):1跟进中 / 2已转化 / 3已废弃
|
||||
func clueStatusName(s int8) string {
|
||||
switch s {
|
||||
|
||||
@@ -121,8 +121,9 @@ func (c *BackendCrmContractController) List() {
|
||||
cond = cond.And("project_id", pid)
|
||||
}
|
||||
}
|
||||
// 周期筛选:与顶部统计同口径(合同有效期 [生效日期, 结束日期] 与所选周期重叠),
|
||||
// period=month/quarter/year 取当期区间,period=custom 用 start_date / end_date
|
||||
// 周期筛选:按「签订日期」归类(与顶部统计同口径)——签订日期落在所选周期内即命中。
|
||||
// period=month/quarter/year 取当期区间,period=custom 用 start_date / end_date;
|
||||
// 签订日期为空的合同(草稿 / 尚未填写)不受周期限制,避免默认「本年」时被隐藏。
|
||||
period := strings.TrimSpace(c.GetString("period"))
|
||||
rangeStart := strings.TrimSpace(c.GetString("start_date"))
|
||||
rangeEnd := strings.TrimSpace(c.GetString("end_date"))
|
||||
@@ -130,67 +131,21 @@ func (c *BackendCrmContractController) List() {
|
||||
rangeStart, rangeEnd = periodRange(period)
|
||||
}
|
||||
if rangeStart != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().Or("expire_date__isnull", true).Or("expire_date__gte", rangeStart))
|
||||
cond = cond.AndCond(orm.NewCondition().Or("sign_date__isnull", true).Or("sign_date__gte", rangeStart))
|
||||
}
|
||||
if rangeEnd != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().Or("effective_date__isnull", true).Or("effective_date__lte", rangeEnd))
|
||||
cond = cond.AndCond(orm.NewCondition().Or("sign_date__isnull", true).Or("sign_date__lte", rangeEnd))
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(cond)
|
||||
|
||||
total, _ := qs.Count()
|
||||
var list []models.TenantCrmContract
|
||||
if total > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
if status != "" {
|
||||
// 已按状态筛选:结果只含单一状态,直接按新→旧分页
|
||||
if _, err := qs.OrderBy("-id").Offset(offset).Limit(pageSize).All(&list); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 列表排列:按状态分组展示 —— 草稿(1) → 异常(5) → 履约中(4) → 已完成(2) → 已作废(3),
|
||||
// 其余状态(NULL / 历史异常值)沉底;组内仍按新→旧。
|
||||
// 先统计各组数量,把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
|
||||
groupConds := []*orm.Condition{
|
||||
orm.NewCondition().And("status", 1),
|
||||
orm.NewCondition().And("status", 5),
|
||||
orm.NewCondition().And("status", 4),
|
||||
orm.NewCondition().And("status", 2),
|
||||
orm.NewCondition().And("status", 3),
|
||||
orm.NewCondition().AndNot("status__in", []int8{1, 5, 4, 2, 3}).Or("status__isnull", true),
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
cursor := 0
|
||||
for _, gc := range groupConds {
|
||||
if cursor >= end {
|
||||
break
|
||||
}
|
||||
// 注意:beego 的 AndCond 会修改接收者,这里用新条件对象包装,避免循环内条件累积
|
||||
groupCond := orm.NewCondition().AndCond(cond).AndCond(gc)
|
||||
cnt, _ := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(groupCond).Count()
|
||||
groupTotal := int(cnt)
|
||||
if groupTotal == 0 {
|
||||
continue
|
||||
}
|
||||
groupStart := cursor
|
||||
groupEnd := cursor + groupTotal
|
||||
cursor = groupEnd
|
||||
// 当前页与本组区间的交集
|
||||
from, to := max(offset, groupStart), min(end, groupEnd)
|
||||
if to <= from {
|
||||
continue
|
||||
}
|
||||
var part []models.TenantCrmContract
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantCrmContract)).SetCond(groupCond).
|
||||
OrderBy("-id").Offset(from - groupStart).Limit(to - from).All(&part); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
list = append(list, part...)
|
||||
}
|
||||
// 列表排列:按「签订日期」倒序,最新签约的合同排最前。
|
||||
// 同日签约多份时按 id 倒序,保证翻页顺序稳定;签订日期为空的合同(草稿 / 尚未签约)沉底。
|
||||
if _, err := qs.OrderBy("-sign_date", "-id").Offset((page - 1) * pageSize).Limit(pageSize).All(&list); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
// 回款逾期日期(取最早,未收齐且已过计划回款日期):合同「逾期」标签与结束日期口径一并参考
|
||||
@@ -216,7 +171,7 @@ func (c *BackendCrmContractController) List() {
|
||||
// Stats GET /backend/crm/contract/stats
|
||||
// 全租户合同统计(排除已作废),供列表页顶部汇总卡片使用。
|
||||
// 支持按周期筛选:period=month/quarter/year(当期)或 custom(start_date~end_date),
|
||||
// 按合同有效期 [生效日期, 结束日期] 与所选周期重叠统计。
|
||||
// 按合同「签订日期」落在所选周期内统计(与列表口径一致)。
|
||||
// 「合同总利润」仅统计非甲方(收款方)合同:我方为甲方的合同是采购付款,只有支出、不存在利润。
|
||||
func (c *BackendCrmContractController) Stats() {
|
||||
claims, err := pipelineClaims(&c.Controller)
|
||||
@@ -236,14 +191,14 @@ func (c *BackendCrmContractController) Stats() {
|
||||
// 我方不参与的合同(our_role=0,如上中游项目的链条合同)不属于我方经营合同,不计入经营统计
|
||||
where := "tenant_id = ? AND delete_time IS NULL AND status <> 3 AND our_role <> 0"
|
||||
args := []interface{}{tenantID}
|
||||
// 按合同有效期 [生效日期, 结束日期] 与所选周期 [start, end] 重叠来统计:
|
||||
// 合同生效日 <= 周期末 且 合同结束日 >= 周期初;日期为空视为不限制对应边界。
|
||||
// 按「签订日期」落在所选周期 [start, end] 内统计(与列表口径一致):
|
||||
// 签订日期为空的合同不受周期限制,视为不限制对应边界。
|
||||
if start != "" {
|
||||
where += " AND (expire_date IS NULL OR expire_date >= ?)"
|
||||
where += " AND (sign_date IS NULL OR sign_date >= ?)"
|
||||
args = append(args, start)
|
||||
}
|
||||
if end != "" {
|
||||
where += " AND (effective_date IS NULL OR effective_date <= ?)"
|
||||
where += " AND (sign_date IS NULL OR sign_date <= ?)"
|
||||
args = append(args, end)
|
||||
}
|
||||
// 「合同总利润」只汇总非甲方(收款方)合同:我方为甲方时是采购付款方,合同金额即支出,不存在利润
|
||||
@@ -268,7 +223,7 @@ func (c *BackendCrmContractController) Stats() {
|
||||
}
|
||||
|
||||
// periodRange 根据 period 返回当期区间 [start, end](YYYY-MM-DD):month/quarter/year。
|
||||
// 统计与列表均以该区间与合同有效期比较(有效期与周期重叠即命中)。
|
||||
// 统计与列表均以该区间与合同「签订日期」比较(签订日期落在区间内即命中)。
|
||||
func periodRange(period string) (string, string) {
|
||||
now := time.Now()
|
||||
loc := now.Location()
|
||||
|
||||
@@ -343,8 +343,12 @@ func (c *BackendCrmPaybackController) Update() {
|
||||
row.ContractAmount = round2(p.ContractAmount)
|
||||
}
|
||||
row.Remark = p.Remark
|
||||
row.ReceiptURL = strings.TrimSpace(p.ReceiptURL)
|
||||
row.ReceiptName = strings.TrimSpace(p.ReceiptName)
|
||||
// 计划级回执已下线(流水回执属于具体回款记录):仅在显式传入非空值时更新,
|
||||
// 避免前端不再提交该字段时把历史数据清空。
|
||||
if strings.TrimSpace(p.ReceiptURL) != "" {
|
||||
row.ReceiptURL = strings.TrimSpace(p.ReceiptURL)
|
||||
row.ReceiptName = strings.TrimSpace(p.ReceiptName)
|
||||
}
|
||||
if p.Status != 0 {
|
||||
row.Status = pickInt8(p.Status, 1, 2)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
@@ -45,8 +46,9 @@ func isTenantAdmin(userID int, tenantID int) bool {
|
||||
return false
|
||||
}
|
||||
var tu models.SystemTenantUser
|
||||
// 令牌 user_id 是 identity_id,查老表前先换算成本租户的老 uid
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", userID).
|
||||
Filter("uid", services.ResolveTenantUID(uint64(tenantID), uint64(userID))).
|
||||
Filter("tid", tenantID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&tu); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -61,9 +62,11 @@ type projectPayload struct {
|
||||
UpstreamAmount float64 `json:"upstream_amount"`
|
||||
DownstreamAmount float64 `json:"downstream_amount"`
|
||||
// Amount 为旧「项目金额」,未传上游金额时回退使用(兼容未升级的调用方)
|
||||
Amount float64 `json:"amount"`
|
||||
Status int8 `json:"status"`
|
||||
StartDate string `json:"start_date"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status int8 `json:"status"`
|
||||
// Reason 异常原因:状态改为「异常(5)」时必填,会写入操作日志便于追溯
|
||||
Reason string `json:"reason"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
// 上下游对接人:仅姓名 + 电话;Contact* 为旧字段,未传新字段时回退使用
|
||||
UpstreamContact string `json:"upstream_contact"`
|
||||
@@ -222,6 +225,8 @@ func (c *BackendCrmProjectController) List() {
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
status := strings.TrimSpace(c.GetString("status"))
|
||||
ownerID := strings.TrimSpace(c.GetString("owner_user_id"))
|
||||
// 年度筛选:按项目开始时间所在年份过滤(该时间由关联合同运行时汇总,见下方内存过滤)
|
||||
year := strings.TrimSpace(c.GetString("year"))
|
||||
|
||||
tenantID := pipelineTenantID(claims)
|
||||
|
||||
@@ -251,65 +256,79 @@ func (c *BackendCrmProjectController) List() {
|
||||
return models.Orm.QueryTable(new(models.TenantCrmProject)).SetCond(cond)
|
||||
}
|
||||
total, _ := projectQuery(buildCond()).Count()
|
||||
// 年度筛选项:按全部候选项目的开始时间汇总(不受当次年度筛选影响,保证下拉里始终能看到所有年份)
|
||||
years := []int{}
|
||||
var list []models.TenantCrmProject
|
||||
if total > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
if status != "" {
|
||||
// 已按状态筛选:结果只含单一状态,直接按新→旧分页
|
||||
if _, err := projectQuery(buildCond()).OrderBy("-id").Offset(offset).Limit(pageSize).All(&list); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
// 排序与年度筛选都依赖「项目开始时间」:取该项目所有关联合同中最早的生效日期
|
||||
// (无生效日期取签订日期),未关联合同时回退旧的手工列 start_date。
|
||||
// 该值是运行时汇总的只读字段(不落库),无法交给 SQL,因此先取全量候选、汇总后在内存里筛选与排序。
|
||||
if _, err := projectQuery(buildCond()).All(&list); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
attachProjectContractPeriod(tenantID, list)
|
||||
startOf := func(p *models.TenantCrmProject) time.Time {
|
||||
if p.ContractStartDate != nil {
|
||||
return *p.ContractStartDate
|
||||
}
|
||||
} else {
|
||||
// 列表排列:按状态分组展示 —— 未开始(1) → 异常(5) → 进行中(2) → 已暂停(4) → 已完成(3),
|
||||
// 其余状态(NULL / 历史异常值)沉底;组内仍按新→旧。
|
||||
// 先统计各组数量,把当前页窗口换算成各组区间,再分别取数按序拼接,保证翻页后整体顺序一致。
|
||||
groupConds := []*orm.Condition{
|
||||
orm.NewCondition().And("status", 1), // 未开始
|
||||
orm.NewCondition().And("status", 5), // 异常
|
||||
orm.NewCondition().And("status", 2), // 进行中
|
||||
orm.NewCondition().And("status", 4), // 已暂停
|
||||
orm.NewCondition().And("status", 3), // 已完成
|
||||
orm.NewCondition().AndNot("status__in", []int8{1, 2, 3, 4, 5}).Or("status__isnull", true),
|
||||
if p.StartDate != nil {
|
||||
return *p.StartDate
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > int(total) {
|
||||
end = int(total)
|
||||
}
|
||||
cursor := 0
|
||||
for _, gc := range groupConds {
|
||||
if cursor >= end {
|
||||
break
|
||||
}
|
||||
// 注意:beego 的 AndCond 会修改接收者,这里用新条件对象包装,避免循环内条件累积
|
||||
groupCond := orm.NewCondition().AndCond(buildCond()).AndCond(gc)
|
||||
cnt, _ := projectQuery(groupCond).Count()
|
||||
groupTotal := int(cnt)
|
||||
if groupTotal == 0 {
|
||||
continue
|
||||
}
|
||||
groupStart := cursor
|
||||
groupEnd := cursor + groupTotal
|
||||
cursor = groupEnd
|
||||
// 当前页与本组区间的交集
|
||||
from, to := max(offset, groupStart), min(end, groupEnd)
|
||||
if to <= from {
|
||||
continue
|
||||
}
|
||||
var part []models.TenantCrmProject
|
||||
if _, err := projectQuery(groupCond).OrderBy("-id").Offset(from - groupStart).Limit(to - from).All(&part); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
list = append(list, part...)
|
||||
return time.Time{}
|
||||
}
|
||||
yearSet := make(map[int]struct{})
|
||||
for i := range list {
|
||||
if d := startOf(&list[i]); !d.IsZero() {
|
||||
yearSet[d.Year()] = struct{}{}
|
||||
}
|
||||
}
|
||||
years = make([]int, 0, len(yearSet))
|
||||
for y := range yearSet {
|
||||
years = append(years, y)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(years)))
|
||||
|
||||
// 年度筛选:仅保留开始时间落在该年度的项目(无开始时间的项目不参与年度筛选)
|
||||
if year != "" {
|
||||
if want, err := strconv.Atoi(year); err == nil && want > 0 {
|
||||
kept := make([]models.TenantCrmProject, 0, len(list))
|
||||
for i := range list {
|
||||
if d := startOf(&list[i]); !d.IsZero() && d.Year() == want {
|
||||
kept = append(kept, list[i])
|
||||
}
|
||||
}
|
||||
list = kept
|
||||
total = int64(len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// 排列:按项目开始时间倒序,最新开始的项目排最前;
|
||||
// 无开始时间的项目沉底,时间相同按 id 倒序,保证翻页顺序稳定。
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
ti, tj := startOf(&list[i]), startOf(&list[j])
|
||||
if ti.IsZero() != tj.IsZero() {
|
||||
return !ti.IsZero()
|
||||
}
|
||||
if !ti.Equal(tj) {
|
||||
return ti.After(tj)
|
||||
}
|
||||
return list[i].ID > list[j].ID
|
||||
})
|
||||
offset := (page - 1) * pageSize
|
||||
if offset > len(list) {
|
||||
offset = len(list)
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > len(list) {
|
||||
end = len(list)
|
||||
}
|
||||
list = list[offset:end]
|
||||
}
|
||||
attachBusinessName(tenantID, list)
|
||||
// 项目起止时间由关联合同汇总(不再依赖手工维护的开始 / 结束日期)
|
||||
attachProjectContractPeriod(tenantID, list)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize,
|
||||
"list": list, "total": total, "years": years,
|
||||
"page": page, "pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -529,6 +548,8 @@ func (c *BackendCrmProjectController) Update() {
|
||||
proj.DownstreamPhone = downPhone
|
||||
proj.ContactPerson = upContact
|
||||
proj.ContactPhone = upPhone
|
||||
// 状态变更(编辑表单亦可改状态):先记住原状态,供操作日志记录流转
|
||||
prevStatus := proj.Status
|
||||
if p.Status != 0 {
|
||||
proj.Status = p.Status
|
||||
}
|
||||
@@ -539,6 +560,14 @@ func (c *BackendCrmProjectController) Update() {
|
||||
proj.Products = p.Products
|
||||
proj.UpdateTime = time.Now()
|
||||
|
||||
// 状态改为「异常」时必须写明原因(与状态流转同一口径),否则日志无法追溯异常成因
|
||||
statusChanged := p.Status != 0 && p.Status != prevStatus
|
||||
reason := strings.TrimSpace(p.Reason)
|
||||
if p.Status == 5 && prevStatus != 5 && reason == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "请填写项目异常原因")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := models.Orm.Update(&proj); err != nil {
|
||||
pipelineErr(&c.Controller, 500, 500, "更新失败: "+err.Error())
|
||||
return
|
||||
@@ -554,6 +583,14 @@ func (c *BackendCrmProjectController) Update() {
|
||||
if proj.DocCategoryID > 0 {
|
||||
_ = services.RenameCrmProjectDocCategory(claims.TenantId, proj.DocCategoryID, proj.ProjectName)
|
||||
}
|
||||
// 编辑表单里改了状态同样要留痕(含异常原因)
|
||||
if statusChanged {
|
||||
logText := "项目状态:" + projectStatusName(prevStatus) + " → " + projectStatusName(proj.Status)
|
||||
if proj.Status == 5 && reason != "" {
|
||||
logText += ";异常原因:" + reason
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "status", logText, claims)
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "update", "更新项目:"+proj.ProjectName, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID})
|
||||
}
|
||||
@@ -730,7 +767,8 @@ func (c *BackendCrmProjectController) ChangeStatus() {
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
var p struct {
|
||||
Status int8 `json:"status"`
|
||||
Status int8 `json:"status"`
|
||||
Reason string `json:"reason"` // 异常原因:status=5 时必填
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
pipelineErr(&c.Controller, 400, 400, "参数错误")
|
||||
@@ -740,6 +778,12 @@ func (c *BackendCrmProjectController) ChangeStatus() {
|
||||
pipelineErr(&c.Controller, 400, 400, "无效的状态值")
|
||||
return
|
||||
}
|
||||
// 置为「异常」时必须写明原因,日志里要能看出是什么导致的
|
||||
reason := strings.TrimSpace(p.Reason)
|
||||
if p.Status == 5 && reason == "" {
|
||||
pipelineErr(&c.Controller, 400, 400, "请填写项目异常原因")
|
||||
return
|
||||
}
|
||||
tenantID := pipelineTenantID(claims)
|
||||
var proj models.TenantCrmProject
|
||||
if err := models.Orm.QueryTable(new(models.TenantCrmProject)).
|
||||
@@ -763,8 +807,11 @@ func (c *BackendCrmProjectController) ChangeStatus() {
|
||||
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "status",
|
||||
"项目状态流转:"+projectStatusName(proj.Status)+" → "+projectStatusName(p.Status), claims)
|
||||
logText := "项目状态流转:" + projectStatusName(proj.Status) + " → " + projectStatusName(p.Status)
|
||||
if p.Status == 5 {
|
||||
logText += ";异常原因:" + reason
|
||||
}
|
||||
crmWriteLog(tenantID, 3, proj.ID, "status", logText, claims)
|
||||
pipelineOk(&c.Controller, map[string]interface{}{"id": proj.ID, "status": p.Status})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"server/services"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
@@ -908,16 +909,139 @@ func (c *BackendFileController) BatchDeleteFilesPermanently() {
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// UploadAvatar POST /backend/uploadavatar(占位)
|
||||
func (c *BackendFileController) UploadAvatar() {
|
||||
c.Data["json"] = map[string]interface{}{"code": 501, "msg": "上传头像暂未实现"}
|
||||
_ = c.ServeJSON()
|
||||
// avatarUploadMaxMB 头像大小上限:头像场景 2MB 足够
|
||||
const avatarUploadMaxMB = 2
|
||||
const avatarUploadMaxBytes = avatarUploadMaxMB * 1024 * 1024
|
||||
|
||||
// avatarAllowedExts 头像允许的图片格式
|
||||
var avatarAllowedExts = map[string]bool{
|
||||
"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true,
|
||||
}
|
||||
|
||||
// UpdateAvatar POST /backend/uploadavatar/:id(占位)
|
||||
// UploadAvatar POST /backend/uploadavatar
|
||||
// 上传并设置「当前登录用户」头像:
|
||||
// 复用文件存储链路(Stage → 查重 → Commit → 落文件表),
|
||||
// 并把最终 URL 写入 yz_auth_identity.avatar 供认证中心 userinfo 下发。
|
||||
func (c *BackendFileController) UploadAvatar() {
|
||||
claims, err := c.backendClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
if err := c.Ctx.Request.ParseMultipartForm(avatarUploadMaxBytes); err != nil {
|
||||
c.jsonErr(400, 400, "解析上传失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fh, header, err := c.GetFile("file")
|
||||
if err != nil || fh == nil {
|
||||
c.jsonErr(400, 400, "请选择要上传的头像")
|
||||
return
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
if header != nil && header.Size > avatarUploadMaxBytes {
|
||||
c.jsonErr(400, 400, fmt.Sprintf("头像大小不能超过%dMB", avatarUploadMaxMB))
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(services.FileExt(header.Filename))
|
||||
if !avatarAllowedExts[ext] {
|
||||
c.jsonErr(400, 400, "头像仅支持 jpg / png / gif / webp 格式")
|
||||
return
|
||||
}
|
||||
|
||||
storageService, err := services.GetStorageService()
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "获取存储服务失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
staged, err := storageService.Stage(fh, header)
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "读取上传文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
// 去重命中或出错时清理临时文件,避免产生垃圾
|
||||
if !committed && staged != nil {
|
||||
_ = storageService.Discard(staged)
|
||||
}
|
||||
}()
|
||||
|
||||
tid := c.effectiveTid(claims)
|
||||
// 头像固定归入「我的个人文件」目录:既不会混进租户共享文件列表,
|
||||
// 又能让本人在文件管理里找到自己上传过的头像。
|
||||
tuid := uint64(claims.UserID)
|
||||
upCtx := services.UploadContext{Source: services.SourceBackend, Tid: tid, Tuid: tuid}
|
||||
|
||||
// 同一张图重复上传:直接复用已有文件,不再占一份空间
|
||||
if exist, e := services.FindDuplicate(services.SourceBackend, upCtx.Scope(), tid, tuid, staged.MD5); e == nil && exist != nil {
|
||||
c.saveAvatarAndReply(claims.UserID, exist.Src, exist.Name)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := storageService.Commit(staged, upCtx)
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "上传头像失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
// 文件台账落库失败不阻断头像设置(头像可用优先)
|
||||
if _, e := services.CreateFileRecord(services.FileUploadParams{
|
||||
Source: services.SourceBackend,
|
||||
Scope: upCtx.Scope(),
|
||||
Tid: tid,
|
||||
Tuid: tuid,
|
||||
Uid: uint64(claims.UserID),
|
||||
Name: header.Filename,
|
||||
Ext: ext,
|
||||
Size: uint64(result.Size),
|
||||
Src: result.URL,
|
||||
ObjectKey: result.Key,
|
||||
Storage: storageService.Type(),
|
||||
MD5: result.MD5,
|
||||
}); e != nil {
|
||||
logs.Warn("[avatar] 头像文件落库失败: %v", e)
|
||||
}
|
||||
|
||||
c.saveAvatarAndReply(claims.UserID, result.URL, header.Filename)
|
||||
}
|
||||
|
||||
// saveAvatarAndReply 写入身份头像并返回结果
|
||||
func (c *BackendFileController) saveAvatarAndReply(identityID int, url, name string) {
|
||||
if err := services.UpdateIdentityAvatar(uint64(identityID), url); err != nil {
|
||||
c.jsonErr(500, 500, "保存头像失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.jsonOK(map[string]interface{}{"url": url, "name": name})
|
||||
}
|
||||
|
||||
// UpdateAvatar POST /backend/uploadavatar/:id
|
||||
// 兼容旧调用:把指定文件的地址设为当前登录用户头像(不重新上传文件)。
|
||||
func (c *BackendFileController) UpdateAvatar() {
|
||||
c.Data["json"] = map[string]interface{}{"code": 501, "msg": "更新头像暂未实现"}
|
||||
_ = c.ServeJSON()
|
||||
claims, err := c.backendClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
idStr := c.Ctx.Input.Param(":id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.jsonErr(400, 400, "无效的文件ID")
|
||||
return
|
||||
}
|
||||
var f models.SystemFile
|
||||
if err := models.Orm.QueryTable(new(models.SystemFile)).
|
||||
Filter("id", id).
|
||||
Filter("tid", c.effectiveTid(claims)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&f); err != nil {
|
||||
c.jsonErr(404, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
c.saveAvatarAndReply(claims.UserID, f.Src, f.Name)
|
||||
}
|
||||
|
||||
// BatchMoveFiles POST /backend/batchMoveFiles
|
||||
|
||||
@@ -447,6 +447,9 @@ func (c *PlatformProductController) CloseProductOrder() {
|
||||
c.jsonErr(500, 500, "关闭失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
if order.Tid > 0 {
|
||||
services.InvalidateTenantModuleCodes(order.Tid)
|
||||
}
|
||||
c.jsonOK(nil, "功能已关闭")
|
||||
}
|
||||
|
||||
@@ -482,5 +485,9 @@ func activateProductOrder(order *models.PlatformProductOrder, operator string) e
|
||||
fields = append(fields, "ExpireTime")
|
||||
}
|
||||
_, err := models.Orm.Update(order, fields...)
|
||||
if err == nil && order.Tid > 0 {
|
||||
// 开通后立即刷新模块权益缓存,避免「已购买却仍被拦」的窗口期
|
||||
services.InvalidateTenantModuleCodes(order.Tid)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -233,7 +233,14 @@ func (c *PlatformTenantUserController) CreateTenantUser() {
|
||||
}
|
||||
p.Password = &hashed
|
||||
if p.Uid == 0 {
|
||||
uid, err := generateTenantUID(p.Tid)
|
||||
uid, err := ensureTenantUIDForNewUser(
|
||||
p.Tid,
|
||||
strOrEmpty(p.Account),
|
||||
strOrEmpty(p.Name),
|
||||
strOrEmpty(p.Phone),
|
||||
strOrEmpty(p.Email),
|
||||
strOrEmpty(p.Password),
|
||||
)
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成租户用户ID失败"}
|
||||
_ = c.ServeJSON()
|
||||
@@ -417,6 +424,41 @@ func (c *PlatformTenantUserController) parsePayload() (tenantUserPayload, bool)
|
||||
return p, true
|
||||
}
|
||||
|
||||
// ensureTenantUIDForNewUser 新增租户用户时确定 uid。
|
||||
//
|
||||
// 统一走 uid:优先取认证中心 identity_id —— 业务表(文件/客户/合同/日程…)
|
||||
// 已在 uid 全量迁移中统一成 identity_id,老表 uid 与它一致,业务侧就无需任何换算。
|
||||
// 认证中心不可用时退回随机 uid(generateTenantUID),保证新增流程不中断。
|
||||
func ensureTenantUIDForNewUser(tid uint64, account, name, phone, email, passwordHash string) (uint64, error) {
|
||||
if tid == 0 {
|
||||
return 0, errors.New("tid 不能为空")
|
||||
}
|
||||
id, err := authsvc.SyncTenantUserAndGetIdentity(authsvc.SyncTenantUserInput{
|
||||
Tid: tid,
|
||||
Account: strings.TrimSpace(account),
|
||||
Name: strings.TrimSpace(name),
|
||||
Phone: strings.TrimSpace(phone),
|
||||
Email: strings.TrimSpace(email),
|
||||
PasswordHash: strings.TrimSpace(passwordHash),
|
||||
Status: 1,
|
||||
})
|
||||
if err == nil && id > 0 {
|
||||
return id, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[auth] 预建身份失败,退回随机 uid: %v", err)
|
||||
}
|
||||
return generateTenantUID(tid)
|
||||
}
|
||||
|
||||
// strOrEmpty 取指针字符串的值,nil 时返回空串
|
||||
func strOrEmpty(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func generateTenantUID(tid uint64) (uint64, error) {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := 0; i < 8; i++ {
|
||||
|
||||
Reference in New Issue
Block a user