feat(health): 积分规则/商品 update/delete + 标签更新端点
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 积分规则: 添加 update/delete service + handler + 路由
- 兑换商品: 添加 update/delete service + handler + 路由
- 文章标签: 添加 update service + handler + 路由
- Web 管理端: 规则/商品列表页支持编辑/删除/启用切换
- Web 管理端: 标签管理页支持编辑、删除传 version
This commit is contained in:
iven
2026-04-26 14:07:21 +08:00
parent f0076aa240
commit 55ec57b2c0
13 changed files with 504 additions and 71 deletions

View File

@@ -252,11 +252,19 @@ export const articleTagApi = {
return data.data;
},
delete: async (id: string) => {
update: async (id: string, req: { name: string; version: number }) => {
const { data } = await client.put<{
success: boolean;
data: ArticleTagItem;
}>(`/health/article-tags/${id}`, req);
return data.data;
},
delete: async (id: string, version: number) => {
const { data } = await client.delete<{
success: boolean;
data: null;
}>(`/health/article-tags/${id}`);
}>(`/health/article-tags/${id}`, { data: { version } });
return data.data;
},
};

View File

@@ -168,6 +168,20 @@ export const pointsApi = {
return data.data;
},
updateRule: async (id: string, req: Partial<CreatePointsRuleReq> & { is_active?: boolean; version: number }) => {
const { data } = await client.put<{
success: boolean;
data: PointsRule;
}>(`/health/admin/points/rules/${id}`, { data: req, version: req.version });
return data.data;
},
deleteRule: async (id: string, version: number) => {
await client.delete(`/health/admin/points/rules/${id}`, {
data: { version },
});
},
// Products
listProducts: async (params?: Record<string, unknown>) => {
const { data } = await client.get<{
@@ -185,6 +199,20 @@ export const pointsApi = {
return data.data;
},
updateProduct: async (id: string, req: Partial<CreatePointsProductReq> & { is_active?: boolean; version: number }) => {
const { data } = await client.put<{
success: boolean;
data: PointsProduct;
}>(`/health/admin/points/products/${id}`, { data: req, version: req.version });
return data.data;
},
deleteProduct: async (id: string, version: number) => {
await client.delete(`/health/admin/points/products/${id}`, {
data: { version },
});
},
// Orders
listOrders: async (params?: Record<string, unknown>) => {
const { data } = await client.get<{

View File

@@ -9,7 +9,7 @@ import {
Tag,
message,
} from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
import {
articleTagApi,
type ArticleTagItem,
@@ -22,6 +22,7 @@ export default function ArticleTagManage() {
const [tags, setTags] = useState<ArticleTagItem[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ArticleTagItem | null>(null);
const [form] = Form.useForm();
const isDark = useThemeMode();
@@ -42,37 +43,50 @@ export default function ArticleTagManage() {
}, [fetchTags]);
const openCreateModal = () => {
setEditing(null);
form.resetFields();
setModalOpen(true);
};
const openEditModal = (record: ArticleTagItem) => {
setEditing(record);
form.setFieldsValue({ name: record.name });
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditing(null);
form.resetFields();
};
const handleCreate = async (values: { name: string; slug?: string; color?: string }) => {
const handleSubmit = async (values: { name: string; slug?: string; color?: string }) => {
try {
const req: CreateTagReq = {
name: values.name,
slug: values.slug,
color: values.color,
};
await articleTagApi.create(req);
message.success('标签创建成功');
if (editing) {
await articleTagApi.update(editing.id, { name: values.name, version: editing.version });
message.success('标签更新成功');
} else {
const req: CreateTagReq = {
name: values.name,
slug: values.slug,
color: values.color,
};
await articleTagApi.create(req);
message.success('标签创建成功');
}
closeModal();
fetchTags();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
'创建失败';
(editing ? '更新失败' : '创建失败');
message.error(errorMsg);
}
};
const handleDelete = async (id: string) => {
const handleDelete = async (record: ArticleTagItem) => {
try {
await articleTagApi.delete(id);
await articleTagApi.delete(record.id, record.version);
message.success('标签已删除');
fetchTags();
} catch {
@@ -138,13 +152,16 @@ export default function ArticleTagManage() {
width: 80,
render: (_: unknown, record: ArticleTagItem) => (
<AuthButton code="health.articles.manage">
<Popconfirm
title="确定删除此标签?"
description="删除后关联的文章将移除该标签"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm>
<div style={{ display: 'flex', gap: 4 }}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => openEditModal(record)} />
<Popconfirm
title="确定删除此标签?"
description="删除后关联的文章将移除该标签"
onConfirm={() => handleDelete(record)}
>
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
</Popconfirm>
</div>
</AuthButton>
),
},
@@ -183,9 +200,9 @@ export default function ArticleTagManage() {
/>
</div>
{/* 新建标签弹窗 */}
{/* 新建/编辑标签弹窗 */}
<Modal
title="新建标签"
title={editing ? '编辑标签' : '新建标签'}
open={modalOpen}
onCancel={closeModal}
onOk={() => form.submit()}
@@ -193,7 +210,7 @@ export default function ArticleTagManage() {
>
<Form
form={form}
onFinish={handleCreate}
onFinish={handleSubmit}
layout="vertical"
style={{ marginTop: 16 }}
>

View File

@@ -19,6 +19,7 @@ import {
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import {
@@ -113,16 +114,23 @@ export default function PointsProductList() {
sort_order?: number;
}) => {
try {
const req: CreatePointsProductReq = {
name: values.name,
product_type: values.product_type,
points_cost: values.points_cost,
stock: values.stock,
description: values.description,
image_url: values.image_url,
sort_order: values.sort_order,
};
await pointsApi.createProduct(req);
if (editing) {
await pointsApi.updateProduct(editing.id, {
...values,
version: editing.version,
});
} else {
const req: CreatePointsProductReq = {
name: values.name,
product_type: values.product_type,
points_cost: values.points_cost,
stock: values.stock,
description: values.description,
image_url: values.image_url,
sort_order: values.sort_order,
};
await pointsApi.createProduct(req);
}
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
form.resetFields();
@@ -135,16 +143,10 @@ export default function PointsProductList() {
// ---- 切换上下架 ----
const handleToggleActive = async (record: PointsProduct) => {
try {
const req: CreatePointsProductReq = {
name: record.name,
product_type: record.product_type,
points_cost: record.points_cost,
stock: record.stock,
description: record.description ?? undefined,
image_url: record.image_url ?? undefined,
sort_order: record.sort_order,
};
await pointsApi.createProduct(req);
await pointsApi.updateProduct(record.id, {
is_active: !record.is_active,
version: record.version,
});
message.success(record.is_active ? '已下架' : '已上架');
fetchData(page, pageSize);
} catch {
@@ -152,6 +154,24 @@ export default function PointsProductList() {
}
};
// ---- 删除 ----
const handleDelete = (record: PointsProduct) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除商品「${record.name}」吗?`,
okType: 'danger',
onOk: async () => {
try {
await pointsApi.deleteProduct(record.id, record.version);
message.success('删除成功');
fetchData(page, pageSize);
} catch {
message.error('删除失败');
}
},
});
};
// ---- 列定义 ----
const columns = [
{
@@ -230,6 +250,15 @@ export default function PointsProductList() {
unCheckedChildren="下架"
onChange={() => handleToggleActive(record)}
/>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
>
</Button>
</Space>
</AuthButton>
),

View File

@@ -19,6 +19,7 @@ import {
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import {
@@ -101,17 +102,24 @@ export default function PointsRuleList() {
streak_30d_bonus?: number;
}) => {
try {
const req: CreatePointsRuleReq = {
event_type: values.event_type,
name: values.name,
description: values.description,
points_value: values.points_value,
daily_cap: values.daily_cap,
streak_7d_bonus: values.streak_7d_bonus,
streak_14d_bonus: values.streak_14d_bonus,
streak_30d_bonus: values.streak_30d_bonus,
};
await pointsApi.createRule(req);
if (editing) {
await pointsApi.updateRule(editing.id, {
...values,
version: editing.version,
});
} else {
const req: CreatePointsRuleReq = {
event_type: values.event_type,
name: values.name,
description: values.description,
points_value: values.points_value,
daily_cap: values.daily_cap,
streak_7d_bonus: values.streak_7d_bonus,
streak_14d_bonus: values.streak_14d_bonus,
streak_30d_bonus: values.streak_30d_bonus,
};
await pointsApi.createRule(req);
}
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
form.resetFields();
@@ -124,19 +132,10 @@ export default function PointsRuleList() {
// ---- 切换启用状态 ----
const handleToggleActive = async (record: PointsRule) => {
try {
// 目前后端没有 toggle 接口,重新创建等同于更新
// 使用 create 接口覆盖同 event_type 的规则
const req: CreatePointsRuleReq = {
event_type: record.event_type,
name: record.name,
description: record.description ?? undefined,
points_value: record.points_value,
daily_cap: record.daily_cap,
streak_7d_bonus: record.streak_7d_bonus,
streak_14d_bonus: record.streak_14d_bonus,
streak_30d_bonus: record.streak_30d_bonus,
};
await pointsApi.createRule(req);
await pointsApi.updateRule(record.id, {
is_active: !record.is_active,
version: record.version,
});
message.success(record.is_active ? '已停用' : '已启用');
fetchData();
} catch {
@@ -144,6 +143,24 @@ export default function PointsRuleList() {
}
};
// ---- 删除 ----
const handleDelete = (record: PointsRule) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除规则「${record.name}」吗?`,
okType: 'danger',
onOk: async () => {
try {
await pointsApi.deleteRule(record.id, record.version);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
},
});
};
// ---- 列定义 ----
const columns = [
{
@@ -234,6 +251,15 @@ export default function PointsRuleList() {
unCheckedChildren="停用"
onChange={() => handleToggleActive(record)}
/>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
>
</Button>
</Space>
</AuthButton>
),

View File

@@ -218,3 +218,15 @@ impl CreateTagReq {
self.name = sanitize_string(&self.name);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateTagReq {
pub name: String,
pub version: i32,
}
impl UpdateTagReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
}
}

View File

@@ -264,6 +264,18 @@ pub struct OfflineEventResp {
// 管理端:带版本号的更新/删除包装
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateRuleWithVersion {
pub data: UpdatePointsRuleReq,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateProductWithVersion {
pub data: UpdatePointsProductReq,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateOfflineEventWithVersion {
pub data: UpdateOfflineEventReq,

View File

@@ -29,6 +29,9 @@ pub enum HealthError {
#[error("日常监测记录不存在")]
DailyMonitoringNotFound,
#[error("积分规则不存在")]
PointsRuleNotFound,
#[error("兑换商品不存在")]
PointsProductNotFound,
@@ -92,6 +95,7 @@ impl From<HealthError> for AppError {
| HealthError::FollowUpTaskNotFound
| HealthError::ConsultationNotFound
| HealthError::ArticleNotFound
| HealthError::PointsRuleNotFound
| HealthError::PointsProductNotFound
| HealthError::PointsOrderNotFound
| HealthError::OfflineEventNotFound

View File

@@ -6,7 +6,7 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::article_dto::{CreateTagReq, TagResp};
use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq};
use crate::service::article_tag_service;
use crate::state::HealthState;
@@ -40,6 +40,24 @@ where
Ok(Json(ApiResponse::ok(result)))
}
pub async fn update_tag<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
mut req: Json<UpdateTagReq>,
) -> Result<Json<ApiResponse<TagResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
req.sanitize();
let result = article_tag_service::update_tag(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteTagReq {
pub version: i32,

View File

@@ -227,6 +227,38 @@ where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
Ok(Json(ApiResponse::ok(result)))
}
pub async fn update_rule<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(rule_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::points_dto::UpdateRuleWithVersion>,
) -> Result<Json<ApiResponse<PointsRuleResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
let mut data = wrapper.data;
data.sanitize();
let result = points_service::update_rule(
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), data, wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn delete_rule<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(rule_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
points_service::delete_rule(
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(())))
}
pub async fn admin_create_product<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -243,6 +275,38 @@ where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
Ok(Json(ApiResponse::ok(result)))
}
pub async fn admin_update_product<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(product_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::points_dto::UpdateProductWithVersion>,
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
let mut data = wrapper.data;
data.sanitize();
let result = points_service::update_product(
&state, ctx.tenant_id, product_id, Some(ctx.user_id), data, wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn admin_delete_product<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(product_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
points_service::delete_product(
&state, ctx.tenant_id, product_id, Some(ctx.user_id), wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(())))
}
pub async fn admin_list_orders<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,

View File

@@ -372,7 +372,8 @@ impl HealthModule {
)
.route(
"/health/article-tags/{id}",
axum::routing::delete(article_tag_handler::delete_tag),
axum::routing::put(article_tag_handler::update_tag)
.delete(article_tag_handler::delete_tag),
)
// 积分商城 — 患者端
.route(
@@ -426,10 +427,20 @@ impl HealthModule {
axum::routing::get(points_handler::list_rules)
.post(points_handler::create_rule),
)
.route(
"/health/admin/points/rules/{id}",
axum::routing::put(points_handler::update_rule)
.delete(points_handler::delete_rule),
)
.route(
"/health/admin/points/products",
axum::routing::post(points_handler::admin_create_product),
)
.route(
"/health/admin/points/products/{id}",
axum::routing::put(points_handler::admin_update_product)
.delete(points_handler::admin_delete_product),
)
.route(
"/health/admin/points/orders",
axum::routing::get(points_handler::admin_list_orders),

View File

@@ -9,7 +9,7 @@ use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use crate::dto::article_dto::{CreateTagReq, TagResp};
use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq};
use crate::entity::article_tag;
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
@@ -66,6 +66,44 @@ pub async fn create_tag(
})
}
pub async fn update_tag(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateTagReq,
) -> HealthResult<TagResp> {
let expected_version = req.version;
let model = article_tag::Entity::find()
.filter(article_tag::Column::Id.eq(id))
.filter(article_tag::Column::TenantId.eq(tenant_id))
.filter(article_tag::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::TagNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: article_tag::ActiveModel = model.into();
active.name = Set(req.name);
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "article_tag.updated", "article_tag")
.with_resource_id(m.id),
&state.db,
).await;
Ok(TagResp {
id: m.id, name: m.name,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_tag(
state: &HealthState,
tenant_id: Uuid,

View File

@@ -645,6 +645,89 @@ pub async fn create_product(
})
}
pub async fn update_product(
state: &HealthState,
tenant_id: Uuid,
product_id: Uuid,
operator_id: Option<Uuid>,
req: UpdatePointsProductReq,
expected_version: i32,
) -> HealthResult<PointsProductResp> {
let model = points_product::Entity::find()
.filter(points_product::Column::Id.eq(product_id))
.filter(points_product::Column::TenantId.eq(tenant_id))
.filter(points_product::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsProductNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_product::ActiveModel = model.into();
if let Some(name) = req.name { active.name = Set(name); }
if let Some(product_type) = req.product_type { active.product_type = Set(product_type); }
if let Some(points_cost) = req.points_cost { active.points_cost = Set(points_cost); }
if let Some(stock) = req.stock { active.stock = Set(stock); }
if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); }
if let Some(description) = req.description { active.description = Set(Some(description)); }
if let Some(service_config) = req.service_config { active.service_config = Set(Some(service_config)); }
if let Some(is_active) = req.is_active { active.is_active = Set(is_active); }
if let Some(sort_order) = req.sort_order { active.sort_order = Set(sort_order); }
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_product.updated", "points_product")
.with_resource_id(m.id),
&state.db,
).await;
Ok(PointsProductResp {
id: m.id, name: m.name, product_type: m.product_type,
points_cost: m.points_cost, stock: m.stock,
image_url: m.image_url, description: m.description,
is_active: m.is_active, sort_order: m.sort_order,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_product(
state: &HealthState,
tenant_id: Uuid,
product_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = points_product::Entity::find()
.filter(points_product::Column::Id.eq(product_id))
.filter(points_product::Column::TenantId.eq(tenant_id))
.filter(points_product::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsProductNotFound)?;
let _next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_product::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_product.deleted", "points_product")
.with_resource_id(m.id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 兑换FIFO 消费积分)
// ---------------------------------------------------------------------------
@@ -1055,6 +1138,89 @@ pub async fn create_rule(
})
}
pub async fn update_rule(
state: &HealthState,
tenant_id: Uuid,
rule_id: Uuid,
operator_id: Option<Uuid>,
req: UpdatePointsRuleReq,
expected_version: i32,
) -> HealthResult<PointsRuleResp> {
let model = points_rule::Entity::find()
.filter(points_rule::Column::Id.eq(rule_id))
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsRuleNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_rule::ActiveModel = model.into();
if let Some(name) = req.name { active.name = Set(name); }
if let Some(description) = req.description { active.description = Set(Some(description)); }
if let Some(points_value) = req.points_value { active.points_value = Set(points_value); }
if let Some(daily_cap) = req.daily_cap { active.daily_cap = Set(daily_cap); }
if let Some(streak_7d_bonus) = req.streak_7d_bonus { active.streak_7d_bonus = Set(streak_7d_bonus); }
if let Some(streak_14d_bonus) = req.streak_14d_bonus { active.streak_14d_bonus = Set(streak_14d_bonus); }
if let Some(streak_30d_bonus) = req.streak_30d_bonus { active.streak_30d_bonus = Set(streak_30d_bonus); }
if let Some(is_active) = req.is_active { active.is_active = Set(is_active); }
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_rule.updated", "points_rule")
.with_resource_id(m.id),
&state.db,
).await;
Ok(PointsRuleResp {
id: m.id, event_type: m.event_type, name: m.name,
description: m.description, points_value: m.points_value,
daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus,
streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus,
is_active: m.is_active, created_at: m.created_at,
updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_rule(
state: &HealthState,
tenant_id: Uuid,
rule_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = points_rule::Entity::find()
.filter(points_rule::Column::Id.eq(rule_id))
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsRuleNotFound)?;
let _next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_rule::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_rule.deleted", "points_rule")
.with_resource_id(m.id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 线下活动
// ---------------------------------------------------------------------------