Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@

SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它将角色权限、带生效日期的组织任职、不可变业绩事件、目标实名确认与审批和受控 Excel 导入放在同一套可审计流程中。

> 当前成熟度:P0 已完成,P1 桌面 Web 与仓库生产准备正在做审查加固;尚未完成真实组织数据落库、业务 UAT 或公司服务器生产部署,不能视为生产系统
> 当前成熟度:P0 与可自动验收的 P1 桌面 Web/仓库工程能力已完成;真实组织数据落库、业务 UAT 和公司服务器生产验收仍是人工 Gate,完成前不能视为生产系统

## 核心能力

- 系统账号、首次改密、会话安全和角色权限矩阵。
- 部门、小组、人员身份和带有效期的组织任职。
- 订单台账与只追加、不覆盖的业绩事件链。
- 所有数据表格和可增长业务清单统一分页:默认 20 条,可选 10/20/50/100 条,并可直接点击页码。
- 按事件发生日期固化人员及组织快照,保留调组前后的历史归属。
- 分层目标下达、责任人签名、总经理/人事审批和修改申请。
- 分层目标下达、责任人实名确认、总经理/人事审批和修改申请。
- 人工录入与受控 `.xlsx` 导入;预检、逐月核对、确认、回滚和幂等证据分离。
- PostgreSQL、React、Fastify 和 Docker Compose 组成的模块化单体。

Expand All @@ -22,7 +23,7 @@ SampleFlow 是面向销售到样业务的业绩与目标管理 Web 系统。它
- GitHub Issue #1—#9 的 P0 工作已关闭;P1 当前状态以 `handoff.md` 与 Roadmap #18 为准。
- `main` 受保护,Pull Request 必须通过 `Typecheck, test, build and audit`。
- 真实历史工作簿已在隔离临时数据库完成功能核对;原文件和行级业务数据不在仓库中。
- P1 产品化、审查加固与部署工作由 [Roadmap #18](https://github.com/Eclipseic1848/SampleFlow/issues/18) 跟踪;当前产品仅验收 1024px/1280px 桌面 Web,移动端 #57 已取消。
- P1 产品化与人工验收由 [Roadmap #18](https://github.com/Eclipseic1848/SampleFlow/issues/18) 跟踪;当前产品仅验收 1024px/1280px 桌面 Web,移动端 #57 已取消;未完成项只剩真实数据 UAT 与公司服务器验收
- 当前事实、数据边界和接手步骤以 [`handoff.md`](handoff.md) 为准。

## 技术栈
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/audit-query.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,16 @@ test("审计查询支持人员、动作、实体、时间和稳定游标过滤",
} finally {
await concurrentClient.end();
}
const numbered = await app.inject({ method: "GET", url: "/api/audits?action=performance.cursor_test&page=6&pageSize=10", headers: { cookie } });
assert.equal(numbered.statusCode, 200, numbered.body);
assert.equal(numbered.json().page, 6);
assert.equal(numbered.json().pageSize, 10);
assert.equal(numbered.json().totalCount, 53);
assert.equal(numbered.json().audits.length, 3);
const mixedPagination = await app.inject({ method: "GET", url: `/api/audits?action=performance.cursor_test&page=1&cursor=${firstData.nextCursor}`, headers: { cookie } });
assert.equal(mixedPagination.statusCode, 400, mixedPagination.body);
const invalidPageSize = await app.inject({ method: "GET", url: "/api/audits?pageSize=15", headers: { cookie } });
assert.equal(invalidPageSize.statusCode, 400, invalidPageSize.body);
const cursorPage = await app.inject({ method: "GET", url: `/api/audits?action=performance.cursor_test&cursor=${firstData.nextCursor}`, headers: { cookie } });
assert.equal(cursorPage.statusCode, 200, cursorPage.body);
const secondRows = cursorPage.json<{ audits: AuditRow[] }>().audits;
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/authorization.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,17 @@ test("账号管理使用稳定搜索分页并审计固定角色组合变更", as
);
await setup.query("insert into user_roles(user_id,role_code,assigned_by) values($1,'salesperson',$2)", [late.rows[0]!.id, scenario.users.admin]);

const numbered = await app.inject({ method: "GET", url: `/api/admin/users?search=${search}&page=2&pageSize=10`, headers: adminHeaders });
assert.equal(numbered.statusCode, 200, numbered.body);
assert.equal(numbered.json().page, 2);
assert.equal(numbered.json().pageSize, 10);
assert.equal(numbered.json().totalCount, 62);
assert.equal(numbered.json().users.length, 10);
const mixedPagination = await app.inject({ method: "GET", url: `/api/admin/users?search=${search}&page=1&cursor=${encodeURIComponent(first.json().nextCursor)}`, headers: adminHeaders });
assert.equal(mixedPagination.statusCode, 400, mixedPagination.body);
const invalidPageSize = await app.inject({ method: "GET", url: "/api/admin/users?pageSize=15", headers: adminHeaders });
assert.equal(invalidPageSize.statusCode, 400, invalidPageSize.body);

const second = await app.inject({
method: "GET",
url: `/api/admin/users?search=${search}&cursor=${encodeURIComponent(first.json().nextCursor)}`,
Expand Down Expand Up @@ -1433,6 +1444,17 @@ test("订单台账用固定快照稳定遍历并保持有界查询次数", async
assert.equal(first.body.previousCursor, null);
assert.ok(first.body.nextCursor);

const numbered = await app.inject({ method: "GET", url: "/api/performance/orders?search=CURSOR-FIX-&page=2&pageSize=10", headers: { cookie: leaderCookie } });
assert.equal(numbered.statusCode, 200, numbered.body);
assert.equal(numbered.json().page, 2);
assert.equal(numbered.json().pageSize, 10);
assert.equal(numbered.json().totalCount, 101);
assert.equal(numbered.json().orders.length, 10);
const mixedPagination = await app.inject({ method: "GET", url: `/api/performance/orders?search=CURSOR-FIX-&page=1&cursor=${encodeURIComponent(first.body.nextCursor!)}`, headers: { cookie: leaderCookie } });
assert.equal(mixedPagination.statusCode, 400, mixedPagination.body);
const invalidPageSize = await app.inject({ method: "GET", url: "/api/performance/orders?pageSize=15", headers: { cookie: leaderCookie } });
assert.equal(invalidPageSize.statusCode, 400, invalidPageSize.body);

const [newOrderId] = await insertRows("CURSOR-FIX-NEW-", 1);
const pages = [first.body];
let nextCursor: string | null = first.body.nextCursor;
Expand Down
26 changes: 24 additions & 2 deletions apps/api/src/modules/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { Database } from "../db.js";
import { generateTemporaryPassword, hashPassword, TEMPORARY_PASSWORD_TTL_MS } from "../security/password.js";
import { postgresBigintIdSchema } from "../validation.js";
import { pageNumberSchema, pageSizeSchema, postgresBigintIdSchema } from "../validation.js";
import { hasAnyRole } from "./auth.js";
import { BUSINESS_DATE_SQL, canReadPerformance, resolveGoalAccess, resolvePerformanceAccess, ROLE_PERMISSION_MATRIX } from "./authorization.js";

const createUserSchema = z.strictObject({ username:z.string().trim().min(2).max(100), displayName:z.string().trim().min(1).max(100), roles:z.array(z.string().trim().min(1)).min(1), personId:postgresBigintIdSchema.nullable().optional() });
const accountListQuerySchema = z.strictObject({ search:z.string().trim().max(100).optional().default(""), cursor:z.string().max(2048).optional() });
const accountListQuerySchema = z.strictObject({ search:z.string().trim().max(100).optional().default(""), cursor:z.string().max(2048).optional(), page:pageNumberSchema.optional(), pageSize:pageSizeSchema.optional() });
const accountCursorSchema = z.strictObject({ version:z.literal(1), userId:postgresBigintIdSchema, search:z.string().max(100), id:postgresBigintIdSchema, cutoffId:postgresBigintIdSchema });
const roleUpdateSchema = z.strictObject({ roles:z.array(z.string().trim().min(1)).min(1) });
const statusSchema = z.object({ isActive:z.boolean() });
Expand All @@ -31,11 +31,33 @@ export async function registerAdmin(app:FastifyInstance,db:Database){
app.get("/api/admin/users",async(request,reply)=>{
const denied=requireAdmin(request,reply);if(denied)return denied;
const parsed=accountListQuerySchema.safeParse(request.query);if(!parsed.success)return reply.code(400).send({message:"账号查询条件无效"});
const numbered=parsed.data.page!==undefined||parsed.data.pageSize!==undefined;
if(numbered&&parsed.data.cursor)return reply.code(400).send({message:"页码与游标不能同时使用"});
const page=parsed.data.page??1;const pageSize=parsed.data.pageSize??20;
const cursor=parsed.data.cursor?decodeAccountCursor(parsed.data.cursor):null;
if(parsed.data.cursor&&(!cursor||cursor.userId!==request.currentUser!.id||cursor.search!==parsed.data.search))return reply.code(400).send({message:"账号分页游标无效或已不适用于当前查询"});
const client=await db.connect();
try{
await client.query("begin transaction isolation level repeatable read read only");
if(numbered){
const result=await client.query<{totalCount:string;users:Array<{id:string;username:string;displayName:string;isActive:boolean;mustChangePassword:boolean;roles:string[]}>}>(
`with filtered as materialized (
select u.id as "__id",u.id::text,u.username,u.display_name as "displayName",u.is_active as "isActive",u.must_change_password as "mustChangePassword",
coalesce(array_agg(ur.role_code order by ur.role_code) filter(where ur.role_code is not null),'{}') as roles
from users u left join user_roles ur on ur.user_id=u.id
where position(lower($1) in lower(u.username))>0 or position(lower($1) in lower(u.display_name))>0
group by u.id
), page_rows as (
select * from filtered order by "__id" limit $2 offset $3
)
select (select count(*)::text from filtered) as "totalCount",
coalesce(jsonb_agg(to_jsonb(page_rows)-'__id' order by page_rows."__id") filter(where page_rows."__id" is not null),'[]'::jsonb) as users
from page_rows`,
[parsed.data.search,pageSize,(page-1)*pageSize],
);
await client.query("commit");
return{users:result.rows[0]!.users,roles:fixedRoles,permissionMatrix:ROLE_PERMISSION_MATRIX,page,pageSize,totalCount:Number(result.rows[0]!.totalCount)};
}
if(!cursor)await client.query("lock table users in share mode");
const result=await client.query<{cutoffId:string|null;users:Array<{id:string;username:string;displayName:string;isActive:boolean;mustChangePassword:boolean;roles:string[]}>}>(
`with cutoff as (select coalesce($3::bigint,max(id)) as id from users), page as (
Expand Down
24 changes: 16 additions & 8 deletions apps/api/src/modules/audits.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { Database } from "../db.js";
import { postgresBigintIdSchema } from "../validation.js";
import { pageNumberSchema, pageSizeSchema, postgresBigintIdSchema } from "../validation.js";
import { canReadGoals, canReadPerformance, performanceScopeSql, performanceScopeValues, resolveGoalAccess, resolvePerformanceAccess } from "./authorization.js";

const auditFiltersSchema = z.strictObject({
Expand All @@ -12,7 +12,7 @@ const auditFiltersSchema = z.strictObject({
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
});
const querySchema = auditFiltersSchema.extend({ cursor: z.string().max(2048).optional() });
const querySchema = auditFiltersSchema.extend({ cursor: z.string().max(2048).optional(), page:pageNumberSchema.optional(), pageSize:pageSizeSchema.optional() });
const auditCursorSchema = z.strictObject({
version: z.literal(1),
userId: postgresBigintIdSchema,
Expand Down Expand Up @@ -50,7 +50,10 @@ export async function registerAudits(app: FastifyInstance, db: Database) {
return reply.code(400).send({ message: "审计查询条件无效" });
}

const { cursor: encodedCursor, ...filters } = parsed.data;
const { cursor: encodedCursor, page:requestedPage, pageSize:requestedPageSize, ...filters } = parsed.data;
const numbered=requestedPage!==undefined||requestedPageSize!==undefined;
if(numbered&&encodedCursor)return reply.code(400).send({message:"页码与游标不能同时使用"});
const page=requestedPage??1;const pageSize=requestedPageSize??20;
const cursor = encodedCursor ? decodeAuditCursor(encodedCursor) : null;
if (encodedCursor && (!cursor || cursor.userId !== request.currentUser.id || JSON.stringify(cursor.filters) !== JSON.stringify(filters))) {
return reply.code(400).send({ message: "审计分页游标无效或已不适用于当前查询" });
Expand Down Expand Up @@ -81,13 +84,14 @@ export async function registerAudits(app: FastifyInstance, db: Database) {
entityType: string;
id: string;
cutoffId: string;
__totalCount: string;
}>(
`with cutoff as (select coalesce($20::bigint,max(id)) as id from audit_logs)
select audit.id::text,
actor_person.id::text as "actorPersonId",actor_user.username as "actorUsername",actor_user.display_name as "actorDisplayName",
audit.action,audit.entity_type as "entityType",audit.entity_id as "entityId",
audit.before_data as "beforeData",audit.after_data as "afterData",audit.created_at as "createdAt",
cutoff.id::text as "cutoffId"
cutoff.id::text as "cutoffId",count(*) over()::text as "__totalCount"
from audit_logs audit
cross join cutoff
left join users actor_user on actor_user.id=audit.actor_user_id
Expand Down Expand Up @@ -142,7 +146,7 @@ export async function registerAudits(app: FastifyInstance, db: Database) {
and ($15::timestamptz is null or audit.created_at<=$15::timestamptz)
and ($16::bigint is null or audit.id<$16::bigint)
and audit.id<=cutoff.id
order by audit.id desc limit $19`,
order by audit.id desc limit $19 offset $21`,
[
systemAdmin,
goalAccess.all,
Expand All @@ -159,17 +163,21 @@ export async function registerAudits(app: FastifyInstance, db: Database) {
cursor?.id ?? null,
request.currentUser.id,
performanceReader,
PAGE_SIZE + 1,
numbered?pageSize:PAGE_SIZE + 1,
cursor?.cutoffId ?? null,
numbered?(page-1)*pageSize:0,
],
);
await client.query("commit");
const hasNext = result.rows.length > PAGE_SIZE;
const audits = result.rows.slice(0, PAGE_SIZE).map(({ cutoffId: _cutoffId, ...row }) => ({
const totalCount=Number(result.rows[0]?.__totalCount??0);
const hasNext = numbered?page*pageSize<totalCount:result.rows.length > PAGE_SIZE;
const rows=numbered?result.rows:result.rows.slice(0,PAGE_SIZE);
const audits = rows.map(({ cutoffId: _cutoffId, __totalCount:_totalCount, ...row }) => ({
...row,
beforeData: redact(row.beforeData),
afterData: redact(row.afterData),
}));
if(numbered)return{audits,page,pageSize,totalCount};
const last = audits.at(-1);
const cutoffId = result.rows[0]?.cutoffId;
return {
Expand Down
Loading