From ca4220f92ce4cd03cf6bc479d3889b3374bea861 Mon Sep 17 00:00:00 2001
From: lzq <357099073@qq.com>
Date: Tue, 29 Sep 2026 18:25:09 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=AA=E4=BA=BA=E4=B8=AD?=
=?UTF-8?q?=E5=BF=83=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/src/api/crmPipeline.js | 22 +-
backend/src/api/user.js | 12 +
backend/src/components/TenantSwitcher.vue | 33 +-
backend/src/stores/menu.js | 7 +-
backend/src/utils/request.js | 22 +-
.../apps/crm/business/components/detail.vue | 88 ++++++
.../views/apps/crm/clue/components/detail.vue | 62 +++-
.../views/apps/crm/clue/components/edit.vue | 299 +++++++++++++++++-
backend/src/views/apps/crm/clue/index.vue | 3 +
.../crm/contract/components/ProductList.vue | 6 +-
.../contract/components/detail_payback.vue | 47 ++-
backend/src/views/apps/crm/contract/index.vue | 13 +-
.../apps/crm/customer/components/edit.vue | 77 ++++-
.../apps/crm/payback/components/create.vue | 81 +----
.../apps/crm/payback/components/detail.vue | 31 +-
.../apps/crm/product/components/edit.vue | 28 +-
.../apps/crm/project/components/detail.vue | 33 +-
.../apps/crm/project/components/edit.vue | 27 ++
backend/src/views/apps/crm/project/index.vue | 30 +-
backend/src/views/user/userProfile.vue | 64 +++-
go/conf/app.conf | 15 +
go/controllers/app_auth.go | 4 +-
go/controllers/backend_admin_user.go | 99 ++++--
go/controllers/backend_auth.go | 20 +-
go/controllers/backend_crm_business.go | 87 +++++
go/controllers/backend_crm_clue.go | 66 +++-
go/controllers/backend_crm_contract.go | 77 +----
go/controllers/backend_crm_payback.go | 8 +-
go/controllers/backend_crm_permission.go | 4 +-
go/controllers/backend_crm_project.go | 159 ++++++----
go/controllers/backend_file.go | 138 +++++++-
go/controllers/platform_product.go | 7 +
go/controllers/platform_tenant_user.go | 44 ++-
go/middleware/jwt.go | 5 +
go/middleware/plan.go | 155 +++++++++
go/routers/backend/backend.go | 4 +
go/routers/router.go | 4 +
go/services/auth/sync.go | 17 +-
go/services/oa_document.go | 5 +-
go/services/operation_log_user.go | 3 +-
go/services/payback_overdue_scheduler.go | 59 ++++
go/services/tenant_quota.go | 75 ++++-
go/services/tenant_user.go | 18 ++
go/services/user_avatar.go | 45 +++
44 files changed, 1793 insertions(+), 310 deletions(-)
create mode 100644 go/middleware/plan.go
create mode 100644 go/services/user_avatar.go
diff --git a/backend/src/api/crmPipeline.js b/backend/src/api/crmPipeline.js
index 897fc55..711775e 100644
--- a/backend/src/api/crmPipeline.js
+++ b/backend/src/api/crmPipeline.js
@@ -33,6 +33,14 @@ export function convertClueToBusiness(id, data) {
return request({ url: `/backend/crm/clue/${id}/convert`, method: "post", data });
}
+/**
+ * 线索状态流转:1跟进中 / 3已废弃。
+ * 「已转化(2)」不走本接口,须用 convertClueToBusiness 完成(会落地客户并锁定线索)。
+ */
+export function changeClueStatus(id, status) {
+ return request({ url: `/backend/crm/clue/${id}/status`, method: "post", data: { status } });
+}
+
/* --------------------------------- 商机 --------------------------------- */
export function getBusinessList(params) {
@@ -60,6 +68,11 @@ export function convertBusinessToProject(id, data) {
return request({ url: `/backend/crm/business/${id}/convert`, method: "post", data });
}
+/** 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单 */
+export function changeBusinessStage(id, stage) {
+ return request({ url: `/backend/crm/business/${id}/stage`, method: "post", data: { stage } });
+}
+
/* --------------------------------- 项目 --------------------------------- */
export function getProjectList(params) {
@@ -82,9 +95,12 @@ export function deleteProject(id) {
return request({ url: `/backend/crm/project/${id}`, method: "delete" });
}
-/** 项目状态流转:仅更新状态(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常) */
-export function changeProjectStatus(id, status) {
- return request({ url: `/backend/crm/project/${id}/status`, method: "post", data: { status } });
+/**
+ * 项目状态流转:仅更新状态(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常)。
+ * status=5(异常)时必须传 reason,后端会校验并写入操作日志。
+ */
+export function changeProjectStatus(id, status, reason = "") {
+ return request({ url: `/backend/crm/project/${id}/status`, method: "post", data: { status, reason } });
}
/**
diff --git a/backend/src/api/user.js b/backend/src/api/user.js
index e90d0a8..df701ca 100644
--- a/backend/src/api/user.js
+++ b/backend/src/api/user.js
@@ -63,6 +63,18 @@ export function bindEmail(userId, email) {
return editUser(userId, { email });
}
+// 上传并设置当前登录用户头像(一步到位:上传文件 + 写入认证中心头像)
+// formData 需包含 file 字段;返回 data.url 为头像地址
+export function uploadAvatar(formData) {
+ return request({
+ url: '/backend/uploadavatar',
+ method: 'post',
+ data: formData,
+ // 上传不设超时;不要手写 Content-Type,需浏览器生成 multipart boundary
+ timeout: 0,
+ });
+}
+
// 删除用户
export function deleteUser(userId) {
return request({
diff --git a/backend/src/components/TenantSwitcher.vue b/backend/src/components/TenantSwitcher.vue
index a2bb26c..cb34581 100644
--- a/backend/src/components/TenantSwitcher.vue
+++ b/backend/src/components/TenantSwitcher.vue
@@ -106,6 +106,28 @@ async function loadTenants() {
}
}
+/**
+ * 企业切换后,上一家的菜单 / 标签页 / 企业信息缓存都必须失效。
+ * 菜单虽然是按 tid 建 key,但旧 key 仍会留在本地,顺手清掉可避免脏数据累积。
+ */
+function clearTenantScopedCache() {
+ try {
+ const removeKeys: string[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && (key.startsWith("menu_cache_") || key === "tabs_list" || key === "active_tab")) {
+ removeKeys.push(key);
+ }
+ }
+ removeKeys.forEach((key) => localStorage.removeItem(key));
+ sessionStorage.removeItem("tabs_list");
+ localStorage.removeItem("tenant");
+ sessionStorage.removeItem("tenant");
+ } catch (e) {
+ console.warn("清理企业级缓存失败:", e);
+ }
+}
+
async function handleSwitch(tid: number | string) {
if (tid === RELOAD_COMMAND) {
await loadTenants();
@@ -115,9 +137,16 @@ async function handleSwitch(tid: number | string) {
const target = tenantOptions.value.find((item) => String(item.tid) === String(tid));
try {
await switchTenant(tid);
+ clearTenantScopedCache();
ElMessage.success(`已切换到「${target?.tenant_name || "新企业"}」`);
- // 菜单、按钮权限、模块套餐均按企业隔离,整页重载确保全部按新企业重新初始化
- setTimeout(() => window.location.reload(), 300);
+ // 切企业后必须离开当前页:当前页可能属于上一家才开通的模块,
+ // 停留在原地会让新企业未开通的功能继续可用。回到首页(门户)锁死入口,
+ // 菜单/权限/套餐再按新企业整页初始化。
+ // 用 replaceState 改 hash(不触发路由跳转),避免用旧企业状态多渲染一次
+ setTimeout(() => {
+ window.history.replaceState({}, document.title, `${window.location.pathname}#/home`);
+ window.location.reload();
+ }, 300);
} catch (e: any) {
ElMessage.error(e?.message || "切换企业失败");
}
diff --git a/backend/src/stores/menu.js b/backend/src/stores/menu.js
index 803dec5..61b0aaf 100644
--- a/backend/src/stores/menu.js
+++ b/backend/src/stores/menu.js
@@ -16,13 +16,16 @@ export const useMenuStore = defineStore('menu', () => {
// 正在加载的 Promise(用于避免重复请求)
let loadingPromise = null;
- // 菜单缓存 key(基于用户类型和角色ID)
+ // 菜单缓存 key(基于用户类型、当前企业与角色ID)
+ // 必须带 tid:菜单是按企业下发的,key 里没有企业会让「切换企业」后
+ // 直接复用上一家的菜单,导致新企业未开通的模块仍能进入。
const getCacheKey = () => {
try {
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
const loginType = userInfo.type || 'user';
const roleId = userInfo.group_id || 0;
- return `menu_cache_${loginType}_${roleId}`;
+ const tid = userInfo.tid || 0;
+ return `menu_cache_${loginType}_${tid}_${roleId}`;
} catch (e) {
return 'menu_cache_default';
}
diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js
index caf1431..81433a3 100644
--- a/backend/src/utils/request.js
+++ b/backend/src/utils/request.js
@@ -51,27 +51,27 @@ service.interceptors.response.use(
const bizMsg = error.response.data?.msg || error.response.data?.message;
switch (error.response.status) {
case 401: {
- // 每个请求最多用新令牌重放一次,避免「401 → 刷新 → 仍 401」反复刷新
- if (error.config?._tokenRetried) {
- return Promise.reject(new Error(bizMsg || 'token无效,请重新登录'));
- }
- // 并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的
+ // 阶段一:并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的
// (登录回跳、另一请求的静默续期都会改 localStorage)。
- // 此时直接用最新令牌重放即可,既省掉一次刷新,也不会误报登录失效。
+ // 此时直接用最新令牌重放,免得白跑一次续期。
const latestToken = localStorage.getItem('token') || '';
const sentToken = String(error.config?.headers?.Authorization || '').replace(/^Bearer\s+/i, '');
- if (latestToken && latestToken !== sentToken) {
+ if (!error.config?._replayedWithLatest && latestToken && latestToken !== sentToken) {
console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', error.config?.url);
error.config.headers['Authorization'] = `Bearer ${latestToken}`;
- error.config._tokenRetried = true;
+ error.config._replayedWithLatest = true;
return service.request(error.config);
}
- // 统一认证:先用 refresh_token 静默换取新令牌并重放原请求,
- // 刷新失败(令牌过期/被吊销/旧本地登录遗留 token)才真正登出。
+ // 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次。
+ // 注意「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,
+ // 只挡一次;否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。
+ if (error.config?._refreshed) {
+ return Promise.reject(new Error(bizMsg || 'token无效,请重新登录'));
+ }
try {
const newToken = await refreshAccessToken();
error.config.headers['Authorization'] = `Bearer ${newToken}`;
- error.config._tokenRetried = true;
+ error.config._refreshed = true;
return service.request(error.config);
} catch (e) {
// 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环)
diff --git a/backend/src/views/apps/crm/business/components/detail.vue b/backend/src/views/apps/crm/business/components/detail.vue
index 915b4d4..df2eb4b 100644
--- a/backend/src/views/apps/crm/business/components/detail.vue
+++ b/backend/src/views/apps/crm/business/components/detail.vue
@@ -7,6 +7,40 @@
@update:model-value="handleClose"
@opened="loadAll"
>
+
+
+
+
+
@@ -62,6 +96,10 @@
diff --git a/backend/src/views/apps/crm/clue/index.vue b/backend/src/views/apps/crm/clue/index.vue
index f9fecc7..062da51 100644
--- a/backend/src/views/apps/crm/clue/index.vue
+++ b/backend/src/views/apps/crm/clue/index.vue
@@ -81,6 +81,9 @@
{{ formatDateTime(row.next_contact_time) }}
+
+ {{ formatDateTime(row.create_time) }}
+
diff --git a/backend/src/views/apps/crm/contract/components/ProductList.vue b/backend/src/views/apps/crm/contract/components/ProductList.vue
index 1bb7c74..4d22142 100644
--- a/backend/src/views/apps/crm/contract/components/ProductList.vue
+++ b/backend/src/views/apps/crm/contract/components/ProductList.vue
@@ -453,7 +453,7 @@ function onProductPick(row, val) {
row.dev_tree = Array.isArray(hit.dev_tree)
? JSON.parse(JSON.stringify(hit.dev_tree))
: safeParseTree(hit.dev_tree);
- // 带出开发总成本作为成本单价;销售单价默认与之相同,可在清单中自行改写
+ // 成本单价取模块报价的「开发总成本」(叶子人天 × 统一人天单价)
const total =
Number(hit.dev_total_price) > 0
? round2(hit.dev_total_price)
@@ -461,7 +461,9 @@ function onProductPick(row, val) {
row.dev_total_price = total;
row.dev_auto_total = total;
row.cost_price = total;
- row.price = total;
+ // 销售单价:产品管理里预制过销售价就直接带出,只有未预制(0/空)时才回落到开发总成本
+ const presetPrice = Number(hit.price) || 0;
+ row.price = presetPrice > 0 ? round2(presetPrice) : total;
// 此处是直接赋值 line_type,不走下拉的 change,需自行兜底类别/单位
if (!row.category) row.category = "软件开发";
if (!row.unit) row.unit = "项";
diff --git a/backend/src/views/apps/crm/contract/components/detail_payback.vue b/backend/src/views/apps/crm/contract/components/detail_payback.vue
index 4c34820..cd788be 100644
--- a/backend/src/views/apps/crm/contract/components/detail_payback.vue
+++ b/backend/src/views/apps/crm/contract/components/detail_payback.vue
@@ -186,19 +186,29 @@
-
+
+ >
+ {{ it.name || `节点${it.seq}` }}
+
+ ¥{{ formatMoney(it.amount) }}
+
+ · 已收 ¥{{ formatMoney(planItemReceived(receiveForm.payback_id, it)) }}
+
+
+
@@ -377,6 +387,15 @@ function planItems(paybackId) {
return Array.isArray(p?.items) ? p.items : [];
}
+/** 指定计划下某节点的已回款(按标记到该节点的记录汇总,兼容历史 received_amount) */
+function planItemReceived(paybackId, item) {
+ const p = planOf(paybackId);
+ const fromRecords = (Array.isArray(p?.children) ? p.children : [])
+ .filter((r) => Number(r.item_seq) === Number(item.seq))
+ .reduce((s, r) => s + (Number(r.amount) || 0), 0);
+ return Math.max(round2(fromRecords), Number(item.received_amount) || 0);
+}
+
/** 节点已回款:按「标记到该节点的回款记录」汇总,兼容历史上按明细直接登记(received_amount)的值 */
function itemReceivedOf(row) {
const plan = row.__plan;
@@ -512,6 +531,17 @@ const receiveForm = reactive({
remark: "",
});
+/**
+ * 可选进度节点:排除已收齐(已回款完毕)的节点。
+ * 已收齐的节点不允许再登记,避免对同一进度重复回款。
+ */
+const selectableItems = computed(() =>
+ planItems(receiveForm.payback_id).filter((it) => {
+ const total = Number(it.amount) || 0;
+ return !(total > 0 && planItemReceived(receiveForm.payback_id, it) >= total);
+ })
+);
+
/**
* 新建回款:以回款计划为先导条件(无计划时不允许登记)。
* 计划与进度节点(按进度选择性付款)都在弹窗内选择。
@@ -688,6 +718,13 @@ defineExpose({ reload });
diff --git a/go/conf/app.conf b/go/conf/app.conf
index 06f57da..dd4a227 100644
--- a/go/conf/app.conf
+++ b/go/conf/app.conf
@@ -45,6 +45,21 @@ auth_enforce = warn
# 额外免鉴权路径前缀(在内置白名单之外追加),多个用英文逗号分隔
auth_whitelist =
+# ==================== 模块套餐鉴权 ====================
+# 未开通模块的接口直接拒绝:off=不启用 / warn=观察模式只记日志 / on=拦截返回403
+# 上线步骤同 auth_enforce:先 warn 跑一段,看日志确认映射无遗漏,再改 on
+plan_enforce = warn
+# 接口前缀 → 模块编码(yz_system_modules.code),多个用英文逗号分隔,格式 前缀:模块编码
+# 按「最长前缀」匹配;未出现在这里的接口不做模块校验(默认放行),可逐模块灰度开启
+# 说明:
+# /backend/crm/* 含招投标等全部 CRM 子功能
+# /backend/erp/* 含客户/供应商/账套/通讯录/组织架构(erp 端)
+# /backend/oa/* 含人事档案/薪酬/日程/通知/文档/组织架构(oa 端)
+# 如需覆盖网站管理(文章/产品/解决方案/Banner/友链/单页/模板/域名)与 AI,
+# 按各自模块编码追加前缀即可,例如:
+# ,/backend/articles:cms,/backend/productsList:cms,/backend/ai:ai,/backend/mcp:ai
+plan_module_paths = /backend/crm:crm,/backend/erp:erp,/backend/oa:oa
+
# JWT 签发者(统一认证中心地址)
jwt_issuer = https://api.yunzer.cn/auth
# HS256 主密钥:留空则沿用内置默认密钥(兼容历史 token)
diff --git a/go/controllers/app_auth.go b/go/controllers/app_auth.go
index 1ca2923..712ed75 100644
--- a/go/controllers/app_auth.go
+++ b/go/controllers/app_auth.go
@@ -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 {
diff --git a/go/controllers/backend_admin_user.go b/go/controllers/backend_admin_user.go
index 2c5accd..ff0de57 100644
--- a/go/controllers/backend_admin_user.go
+++ b/go/controllers/backend_admin_user.go
@@ -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()
diff --git a/go/controllers/backend_auth.go b/go/controllers/backend_auth.go
index 99d0e7f..e3aae01 100644
--- a/go/controllers/backend_auth.go
+++ b/go/controllers/backend_auth.go
@@ -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": "用户不存在"})
diff --git a/go/controllers/backend_crm_business.go b/go/controllers/backend_crm_business.go
index 24027d4..f241075 100644
--- a/go/controllers/backend_crm_business.go
+++ b/go/controllers/backend_crm_business.go
@@ -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)
diff --git a/go/controllers/backend_crm_clue.go b/go/controllers/backend_crm_clue.go
index 203d564..ff47434 100644
--- a/go/controllers/backend_crm_clue.go
+++ b/go/controllers/backend_crm_clue.go
@@ -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 {
diff --git a/go/controllers/backend_crm_contract.go b/go/controllers/backend_crm_contract.go
index 0422f27..20694bf 100644
--- a/go/controllers/backend_crm_contract.go
+++ b/go/controllers/backend_crm_contract.go
@@ -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()
diff --git a/go/controllers/backend_crm_payback.go b/go/controllers/backend_crm_payback.go
index 250c4eb..8ba2145 100644
--- a/go/controllers/backend_crm_payback.go
+++ b/go/controllers/backend_crm_payback.go
@@ -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)
}
diff --git a/go/controllers/backend_crm_permission.go b/go/controllers/backend_crm_permission.go
index 033ebef..f9e7476 100644
--- a/go/controllers/backend_crm_permission.go
+++ b/go/controllers/backend_crm_permission.go
@@ -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 {
diff --git a/go/controllers/backend_crm_project.go b/go/controllers/backend_crm_project.go
index 2236b14..73797c2 100644
--- a/go/controllers/backend_crm_project.go
+++ b/go/controllers/backend_crm_project.go
@@ -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})
}
diff --git a/go/controllers/backend_file.go b/go/controllers/backend_file.go
index 0439617..8d6309c 100644
--- a/go/controllers/backend_file.go
+++ b/go/controllers/backend_file.go
@@ -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
diff --git a/go/controllers/platform_product.go b/go/controllers/platform_product.go
index 17afd85..0e64515 100644
--- a/go/controllers/platform_product.go
+++ b/go/controllers/platform_product.go
@@ -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
}
diff --git a/go/controllers/platform_tenant_user.go b/go/controllers/platform_tenant_user.go
index 5b35cbb..1ae18bf 100644
--- a/go/controllers/platform_tenant_user.go
+++ b/go/controllers/platform_tenant_user.go
@@ -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++ {
diff --git a/go/middleware/jwt.go b/go/middleware/jwt.go
index c4e4f35..270c434 100644
--- a/go/middleware/jwt.go
+++ b/go/middleware/jwt.go
@@ -183,6 +183,11 @@ func JWTAuthMiddleware() beego.FilterFunc {
ctx.Input.SetData("userId", claims.UserID)
ctx.Input.SetData("username", claims.Username)
ctx.Input.SetData("tenantId", claims.TenantId)
+ // 兼容历史键名:部分控制器(menu / admin_user)读的是 uid / tid,
+ // 且按 uint64 断言,这里补写一份,避免它们静默拿到 0。
+ // 值语义与令牌一致:uid 为认证中心 identity_id。
+ ctx.Input.SetData("uid", uint64(claims.UserID))
+ ctx.Input.SetData("tid", uint64(claims.TenantId))
// 从token中获取用户类型(如果token中没有,则默认为"user")
userType := claims.UserType
diff --git a/go/middleware/plan.go b/go/middleware/plan.go
new file mode 100644
index 0000000..80e15a2
--- /dev/null
+++ b/go/middleware/plan.go
@@ -0,0 +1,155 @@
+package middleware
+
+import (
+ "errors"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+ "sync"
+
+ "server/pkg/jwtutil"
+ "server/services"
+
+ beego "github.com/beego/beego/v2/server/web"
+ "github.com/beego/beego/v2/server/web/context"
+)
+
+// 模块套餐鉴权(PlanAuth)
+//
+// 把「接口前缀」映射到「功能模块编码」(yz_system_modules.code),
+// 当前租户生效套餐(含已开通的单品订单)未包含该模块时拒绝访问。
+//
+// 与 JWT 鉴权一致支持三档模式(app.conf: plan_enforce):
+// - off :完全不启用
+// - warn:观察模式,只记录「本会被拦截」的请求(默认,便于上线前核对映射)
+// - on :真正拦截,未开通返回 403
+//
+// 映射表(app.conf: plan_module_paths)示例:
+// plan_module_paths = /backend/crm:crm,/backend/erp:erp,/backend/oa:oa
+// 按最长前缀匹配;未出现在映射表中的接口不做模块校验(默认放行),
+// 因此可以按模块逐个灰度开启,不会误伤用户/角色/文件/日志等通用接口。
+
+const planMappingSeparator = ":"
+
+var (
+ errPlanNoToken = errors.New("未提供认证信息")
+ errPlanBadToken = errors.New("认证信息格式错误")
+
+ planOnce sync.Once
+ planMode = AuthModeWarn
+ planRoutes []planRoute
+)
+
+// planRoute 接口前缀 → 模块编码
+type planRoute struct {
+ prefix string
+ module string
+}
+
+func planInit() {
+ planOnce.Do(func() {
+ if v, err := beego.AppConfig.String("plan_enforce"); err == nil {
+ switch strings.ToLower(strings.TrimSpace(v)) {
+ case AuthModeOff, AuthModeWarn, AuthModeOn:
+ planMode = strings.ToLower(strings.TrimSpace(v))
+ }
+ }
+
+ raw, _ := beego.AppConfig.String("plan_module_paths")
+ for _, item := range strings.Split(raw, ",") {
+ item = strings.TrimSpace(item)
+ if item == "" {
+ continue
+ }
+ parts := strings.SplitN(item, planMappingSeparator, 2)
+ if len(parts) != 2 {
+ log.Printf("[plan] 忽略无效映射(应为 前缀:模块编码): %s", item)
+ continue
+ }
+ prefix := strings.TrimSpace(parts[0])
+ module := services.NormalizeModuleCode(parts[1])
+ if prefix == "" || module == "" {
+ log.Printf("[plan] 忽略无效映射(前缀或模块编码为空): %s", item)
+ continue
+ }
+ planRoutes = append(planRoutes, planRoute{prefix: prefix, module: module})
+ }
+
+ // 最长前缀优先:/backend/crm/bidding 必须先于 /backend/crm 命中
+ sort.Slice(planRoutes, func(i, j int) bool {
+ return len(planRoutes[i].prefix) > len(planRoutes[j].prefix)
+ })
+ log.Printf("[plan] 模块套餐鉴权模式=%s,接口映射=%d 条", planMode, len(planRoutes))
+ })
+}
+
+// planModuleOf 返回路径归属的模块编码;未配置归属时返回空字符串
+func planModuleOf(path string) string {
+ for _, r := range planRoutes {
+ if strings.HasPrefix(path, r.prefix) {
+ return r.module
+ }
+ }
+ return ""
+}
+
+// PlanAuthMiddleware 模块套餐鉴权中间件
+func PlanAuthMiddleware() beego.FilterFunc {
+ return func(ctx *context.Context) {
+ planInit()
+ if planMode == AuthModeOff || len(planRoutes) == 0 {
+ return
+ }
+ if ctx.Input.Method() == http.MethodOptions {
+ return
+ }
+
+ path := cleanPath(ctx.Request.RequestURI)
+ if !isProtected(path) || isWhitelisted(path) {
+ return
+ }
+
+ module := planModuleOf(path)
+ if module == "" {
+ return
+ }
+
+ claims, err := planClaims(ctx)
+ if err != nil {
+ // 未登录 / 令牌无效:交给 JWTAuthMiddleware 与各控制器按原逻辑处理
+ return
+ }
+ if claims.TenantId <= 0 {
+ return
+ }
+ if services.IsModuleEnabled(uint64(claims.TenantId), module) {
+ return
+ }
+
+ reason := "模块未开通: " + module
+ if planMode == AuthModeWarn {
+ warnLog(path, reason)
+ return
+ }
+
+ ctx.Output.SetStatus(http.StatusForbidden)
+ _ = ctx.Output.JSON(map[string]interface{}{
+ "code": 403,
+ "msg": "该功能未开通,请先购买对应套餐",
+ }, false, false)
+ }
+}
+
+// planClaims 解析 Authorization 头中的 JWT(仅用于本中间件的模块归属判断)
+func planClaims(ctx *context.Context) (*jwtutil.Claims, error) {
+ authHeader := ctx.Request.Header.Get("Authorization")
+ if authHeader == "" {
+ return nil, errPlanNoToken
+ }
+ parts := strings.SplitN(authHeader, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ return nil, errPlanBadToken
+ }
+ return jwtutil.ParseToken(parts[1])
+}
diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go
index 7ae4752..20dc327 100644
--- a/go/routers/backend/backend.go
+++ b/go/routers/backend/backend.go
@@ -422,12 +422,16 @@ func registerOrganizationRoutes(module string) {
beego.Router("/backend/crm/clue", &controllers.BackendCrmClueController{}, "post:Create")
beego.Router("/backend/crm/clue/:id", &controllers.BackendCrmClueController{}, "get:Detail;put:Update;delete:Delete")
beego.Router("/backend/crm/clue/:id/convert", &controllers.BackendCrmClueController{}, "post:Convert")
+ // 线索状态流转:1跟进中 / 3已废弃(「已转化」须走「转化商机」流程)
+ beego.Router("/backend/crm/clue/:id/status", &controllers.BackendCrmClueController{}, "post:ChangeStatus")
// CRM商机管理(商机 -> 项目转化)
beego.Router("/backend/crm/business/list", &controllers.BackendCrmBusinessController{}, "get:List")
beego.Router("/backend/crm/business", &controllers.BackendCrmBusinessController{}, "post:Create")
beego.Router("/backend/crm/business/:id", &controllers.BackendCrmBusinessController{}, "get:Detail;put:Update;delete:Delete")
beego.Router("/backend/crm/business/:id/convert", &controllers.BackendCrmBusinessController{}, "post:Convert")
+ // 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单
+ beego.Router("/backend/crm/business/:id/stage", &controllers.BackendCrmBusinessController{}, "post:ChangeStage")
// CRM项目管理
beego.Router("/backend/crm/project/list", &controllers.BackendCrmProjectController{}, "get:List")
diff --git a/go/routers/router.go b/go/routers/router.go
index f49f8a7..6bc844a 100644
--- a/go/routers/router.go
+++ b/go/routers/router.go
@@ -40,6 +40,10 @@ func init() {
// 放在操作日志之前注册,保证操作日志能取到 userId。
beego.InsertFilter("*", beego.BeforeRouter, middleware.JWTAuthMiddleware())
+ // 模块套餐鉴权:租户当前套餐(含单品订单)未开通的模块,其接口直接拒绝。
+ // 映射与开关见 app.conf 的 plan_module_paths / plan_enforce(默认 warn 只记日志)。
+ beego.InsertFilter("*", beego.BeforeRouter, middleware.PlanAuthMiddleware())
+
// 全局操作日志:请求开始采集并异步写入
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
diff --git a/go/services/auth/sync.go b/go/services/auth/sync.go
index de3873a..616dba0 100644
--- a/go/services/auth/sync.go
+++ b/go/services/auth/sync.go
@@ -32,11 +32,24 @@ type SyncTenantUserInput struct {
// 注意:密码属于身份层(一人一份),因此在任一企业修改密码,
// 该用户在其他企业的登录密码会同步变化——这是统一认证的预期行为。
func SyncTenantUser(in SyncTenantUserInput) error {
+ _, err := SyncTenantUserAndGetIdentity(in)
+ return err
+}
+
+// SyncTenantUserAndGetIdentity 同 SyncTenantUser,但额外返回认证中心身份ID。
+//
+// 用途:新增租户用户时先把身份建出来,再用 identity_id 作为
+// yz_system_tenant_user.uid——业务表(文件/客户/合同…)已在 uid 全量迁移中
+// 统一成 identity_id,老表 uid 只有与它一致,业务侧才不需要任何换算。
+func SyncTenantUserAndGetIdentity(in SyncTenantUserInput) (uint64, error) {
identity, err := findOrCreateIdentity(in)
if err != nil {
- return err
+ return 0, err
}
- return upsertTenantBinding(identity, in)
+ if err := upsertTenantBinding(identity, in); err != nil {
+ return 0, err
+ }
+ return identity.ID, nil
}
// RemoveTenantUser 删除该用户在指定企业的绑定(保留身份本身,
diff --git a/go/services/oa_document.go b/go/services/oa_document.go
index a863462..08b0410 100644
--- a/go/services/oa_document.go
+++ b/go/services/oa_document.go
@@ -61,9 +61,10 @@ func NewOaDocActor(tid int, uid uint64) OaDocActor {
actor := OaDocActor{Tid: tid, UID: uid, OrgIDs: []uint64{}}
var user models.SystemTenantUser
+ // uid 来自令牌(identity_id),查老表前先换算成老 uid
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
- Filter("uid", uid).
+ Filter("uid", ResolveTenantUID(uint64(tid), uid)).
Filter("delete_time__isnull", true).
One(&user); err != nil {
// 查不到归属信息时退化为"普通用户",仅能看公开文档
@@ -1472,7 +1473,7 @@ func oaDocShareTargetName(tid int, shareType int8, targetID uint64) string {
}
var u models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
- Filter("tid", tid).Filter("uid", targetID).Filter("delete_time__isnull", true).One(&u); err == nil {
+ Filter("tid", tid).Filter("uid", ResolveTenantUID(uint64(tid), targetID)).Filter("delete_time__isnull", true).One(&u); err == nil {
if u.Name != nil {
return *u.Name
}
diff --git a/go/services/operation_log_user.go b/go/services/operation_log_user.go
index f12f72d..1dd366c 100644
--- a/go/services/operation_log_user.go
+++ b/go/services/operation_log_user.go
@@ -11,9 +11,10 @@ func OperationLogUser(tid *uint64, userID uint64) (account, name string) {
if tid != nil && *tid != 0 {
var user models.SystemTenantUser
+ // userID 是令牌里的 identity_id,查老表前先换算成老 uid
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", *tid).
- Filter("uid", userID).
+ Filter("uid", ResolveTenantUID(*tid, userID)).
One(&user); err == nil {
if user.Account != nil {
account = *user.Account
diff --git a/go/services/payback_overdue_scheduler.go b/go/services/payback_overdue_scheduler.go
index b522ecd..18556c5 100644
--- a/go/services/payback_overdue_scheduler.go
+++ b/go/services/payback_overdue_scheduler.go
@@ -153,6 +153,59 @@ func paybackPlanOverdueFor(plan models.TenantCrmPayback, today string) bool {
return false
}
+// crmProjectStatusText 项目状态文案(与控制器层 projectStatusName 保持一致,用于操作日志)。
+func crmProjectStatusText(s int8) string {
+ switch s {
+ case 1:
+ return "未开始"
+ case 2:
+ return "进行中"
+ case 3:
+ return "已完成"
+ case 4:
+ return "已暂停"
+ case 5:
+ return "异常"
+ }
+ return "未知"
+}
+
+// crmContractStatusText 合同状态文案(与控制器层 contractStatusName 保持一致,用于操作日志)。
+func crmContractStatusText(s int8) string {
+ switch s {
+ case 1:
+ return "草稿"
+ case 2:
+ return "已完成"
+ case 3:
+ return "已作废"
+ case 4:
+ return "履约中"
+ case 5:
+ return "异常"
+ }
+ return "未知"
+}
+
+// writeOverdueStatusLog 逾期联动自动改状态时写入 CRM 操作日志。
+// 自动置「异常」与人工流转同一口径:日志里必须写明原因,操作者记为「系统自动」,便于事后追溯。
+// relatedType=3 与控制器层一致(项目 / 合同的操作日志同属该分类)。
+func writeOverdueStatusLog(tenantID string, relatedID uint64, content string, now time.Time) {
+ if models.Orm == nil || relatedID == 0 {
+ return
+ }
+ log := models.TenantCrmOperateLog{
+ TenantID: tenantID,
+ RelatedType: 3,
+ RelatedID: relatedID,
+ Action: "status",
+ Content: content,
+ OperatorName: "系统自动",
+ CreateTime: now,
+ }
+ _, _ = models.Orm.Insert(&log)
+}
+
// applyContractOverdue 合同置「异常」(5),并联动项目置「异常」(5)。
// 返回 (合同是否更新, 项目是否更新)。
func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (int, int) {
@@ -174,6 +227,9 @@ func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (in
return 0, 0
}
contractUpdated = 1
+ // 自动改异常同样写明原因,否则日志里只看到"变异常"却不知为何
+ writeOverdueStatusLog(tenantID, contractID,
+ "合同状态自动流转:"+crmContractStatusText(contract.Status)+" → 异常;异常原因:回款计划逾期未收齐", now)
}
// 合同关联项目联动置异常
projectUpdated := 0
@@ -186,6 +242,9 @@ func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (in
Filter("id", proj.ID).Filter("tenant_id", tenantID).
Update(map[string]interface{}{"status": int8(5), "update_time": now}); err == nil {
projectUpdated = 1
+ writeOverdueStatusLog(tenantID, proj.ID,
+ "项目状态自动流转:"+crmProjectStatusText(proj.Status)+" → 异常;异常原因:关联合同「"+
+ contract.ContractName+"」回款计划逾期未收齐", now)
}
}
}
diff --git a/go/services/tenant_quota.go b/go/services/tenant_quota.go
index 306b08e..84af201 100644
--- a/go/services/tenant_quota.go
+++ b/go/services/tenant_quota.go
@@ -5,6 +5,7 @@ import (
"math"
"sort"
"strings"
+ "sync"
"time"
"server/models"
@@ -151,6 +152,75 @@ func NormalizeModuleCode(code string) string {
return strings.ToLower(strings.TrimSpace(code))
}
+// ---------------------------------------------------------------- 模块权益鉴权
+
+// moduleCodesCacheTTL 模块权益缓存时长。
+// 模块鉴权中间件会在每个业务请求上判断开通状态,直查数据库代价太高;
+// 60 秒延迟对「购买后立即可用」没有实际影响(前端刷新即可)。
+const moduleCodesCacheTTL = 60 * time.Second
+
+type tenantModuleCodesItem struct {
+ codes map[string]bool
+ expireAt time.Time
+}
+
+var (
+ tenantModuleCodesMu sync.RWMutex
+ tenantModuleCodesCache = map[uint64]tenantModuleCodesItem{}
+)
+
+// GetTenantModuleCodesCached 带进程内缓存的模块权益查询(供请求级鉴权使用)
+func GetTenantModuleCodesCached(tid uint64) map[string]bool {
+ now := time.Now()
+ tenantModuleCodesMu.RLock()
+ item, ok := tenantModuleCodesCache[tid]
+ tenantModuleCodesMu.RUnlock()
+ if ok && now.Before(item.expireAt) {
+ return item.codes
+ }
+
+ codes := GetTenantModuleCodes(tid)
+
+ tenantModuleCodesMu.Lock()
+ // 顺带清理过期项,避免长期运行后内存堆积
+ if len(tenantModuleCodesCache) > 5000 {
+ for k, v := range tenantModuleCodesCache {
+ if now.After(v.expireAt) {
+ delete(tenantModuleCodesCache, k)
+ }
+ }
+ }
+ tenantModuleCodesCache[tid] = tenantModuleCodesItem{codes: codes, expireAt: now.Add(moduleCodesCacheTTL)}
+ tenantModuleCodesMu.Unlock()
+ return codes
+}
+
+// InvalidateTenantModuleCodes 清除某租户的模块权益缓存(套餐 / 订单变更后调用)
+func InvalidateTenantModuleCodes(tid uint64) {
+ if tid == 0 {
+ return
+ }
+ tenantModuleCodesMu.Lock()
+ delete(tenantModuleCodesCache, tid)
+ tenantModuleCodesMu.Unlock()
+}
+
+// IsModuleEnabled 判断租户是否已开通指定功能模块。
+//
+// 口径与 /backend/modules/getTenantList 保持一致:租户没有任何模块权益时视为「全开」,
+// 避免历史租户或未配置套餐的环境被一刀切拦截。
+func IsModuleEnabled(tid uint64, moduleCode string) bool {
+ code := NormalizeModuleCode(moduleCode)
+ if code == "" || tid == 0 {
+ return true
+ }
+ codes := GetTenantModuleCodesCached(tid)
+ if len(codes) == 0 {
+ return true
+ }
+ return codes[code]
+}
+
// DayStart 取时间所在自然日的 0 点(有效期按「日」计算,不精确到秒)
func DayStart(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
@@ -277,9 +347,10 @@ func CheckTenantUserQuota(tid, uid uint64) (*TenantQuotaCheck, error) {
result.Quota = quota
if uid > 0 {
+ // uid 可能来自令牌(identity_id),查老表前先换算
exist, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
- Filter("uid", uid).
+ Filter("uid", ResolveTenantUID(tid, uid)).
Filter("delete_time__isnull", true).
Count()
if err == nil && exist > 0 {
@@ -442,6 +513,8 @@ func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
Update(update); err != nil {
return 0, err
}
+ // 套餐变更后清掉模块权益缓存,避免「刚换套餐仍被拦/仍在放行」的延迟
+ InvalidateTenantModuleCodes(tid)
return newQuota, nil
}
diff --git a/go/services/tenant_user.go b/go/services/tenant_user.go
index 937f857..f9bb84a 100644
--- a/go/services/tenant_user.go
+++ b/go/services/tenant_user.go
@@ -4,8 +4,26 @@ import (
"strings"
"server/models"
+ authsvc "server/services/auth"
)
+// ResolveTenantUID 把「令牌中的 user_id(认证中心 identity_id)」换算成
+// 老表 yz_system_tenant_user.uid。
+//
+// 背景:uid 全量迁移脚本(scripts/uidmigrate)刻意跳过 yz_system_tenant_user
+// (见其 skipTables),业务表列迁成 identity_id,而老表 uid 仍是老值。
+// 因此凡是用 identity_id 去查老表的场景都必须先经过这里换算;
+// 两者已经对齐的环境(或传入的本来就是老 uid)原值返回。
+func ResolveTenantUID(tid, userID uint64) uint64 {
+ if tid == 0 || userID == 0 {
+ return 0
+ }
+ if legacy := authsvc.FindLegacyUID(tid, userID); legacy > 0 {
+ return legacy
+ }
+ return userID
+}
+
// BindTenantUser 绑定用户到租户(若已存在则更新状态/默认值)
func BindTenantUser(tid, uid uint64, account, name, phone, email *string, sex *uint8, birth *string, password *string, isDefault, status int8, remark *string) (uint64, error) {
var existed models.SystemTenantUser
diff --git a/go/services/user_avatar.go b/go/services/user_avatar.go
new file mode 100644
index 0000000..0aede35
--- /dev/null
+++ b/go/services/user_avatar.go
@@ -0,0 +1,45 @@
+package services
+
+import (
+ "errors"
+ "strings"
+ "time"
+
+ "server/models"
+)
+
+// 用户头像统一存在认证中心身份表 yz_auth_identity.avatar:
+// /auth/userinfo 会返回该字段,前端登录回跳、顶部栏、个人中心都从这里取,
+// 业务侧(yz_system_tenant_user)不再另存一份,避免两处不一致。
+
+// UpdateIdentityAvatar 更新身份头像
+func UpdateIdentityAvatar(identityID uint64, avatarURL string) error {
+ if identityID == 0 {
+ return errors.New("身份ID不能为空")
+ }
+ url := strings.TrimSpace(avatarURL)
+ if url == "" {
+ return errors.New("头像地址不能为空")
+ }
+ _, err := models.Orm.QueryTable(new(models.AuthIdentity)).
+ Filter("id", identityID).
+ Update(map[string]interface{}{
+ "avatar": url,
+ "update_time": time.Now(),
+ })
+ return err
+}
+
+// GetIdentityAvatar 读取身份头像;未设置或身份不存在时返回空字符串
+func GetIdentityAvatar(identityID uint64) string {
+ if identityID == 0 {
+ return ""
+ }
+ var identity models.AuthIdentity
+ if err := models.Orm.QueryTable(new(models.AuthIdentity)).
+ Filter("id", identityID).
+ One(&identity); err != nil || identity.Avatar == nil {
+ return ""
+ }
+ return strings.TrimSpace(*identity.Avatar)
+}