@Aop("redis")
 public boolean updateTags(String topicId, @Param("tags") Set<String> tags) {
   if (Strings.isBlank(topicId) || tags == null) {
     return false;
   }
   Topic topic = dao.fetch(Topic.class, topicId);
   if (topic == null) return false;
   Set<String> oldTags = topic.getTags();
   if (oldTags == null) oldTags = new HashSet<>();
   log.debugf("update from '%s' to '%s'", oldTags, tags);
   topic.setTags(tags);
   dao.update(topic, "tags");
   Set<String> newTags = new HashSet<>(tags);
   newTags.removeAll(oldTags);
   Set<String> removeTags = new HashSet<>(oldTags);
   ;
   removeTags.remove(tags);
   fillTopic(topic, null);
   Date lastReplyTime = topic.getCreateTime();
   if (topic.getLastComment() != null) lastReplyTime = topic.getLastComment().getCreateTime();
   Pipeline pipe = jedis().pipelined();
   for (String tag : removeTags) {
     pipe.zrem(RKEY_TOPIC_TAG + tag.toLowerCase().trim(), topic.getId());
     pipe.zincrby(RKEY_TOPIC_TAG_COUNT, -1, tag.toLowerCase().trim());
   }
   for (String tag : newTags) {
     pipe.zadd(RKEY_TOPIC_TAG + tag.toLowerCase().trim(), lastReplyTime.getTime(), topic.getId());
     pipe.zincrby(RKEY_TOPIC_TAG_COUNT, 1, tag.toLowerCase().trim());
   }
   pipe.sync();
   return true;
 }
  @Async
  protected void notifyUsers(Topic topic, TopicReply reply, String cnt, int userId) {
    String replyAuthorName = dao.fetch(User.class, userId).getName();
    // 通知原本的作者
    if (topic.getUserId() != userId) {
      String alert = replyAuthorName + "回复了您的帖子";
      pushUser(
          topic.getUserId(),
          alert,
          topic.getId(),
          replyAuthorName,
          topic.getTitle(),
          PushService.PUSH_TYPE_REPLY);
    }

    Set<String> ats = findAt(cnt, 5);
    for (String at : ats) {
      User user = dao.fetch(User.class, at);
      if (user == null) continue;
      if (topic.getUserId() == user.getId()) continue; // 前面已经发过了
      if (userId == user.getId()) continue; // 自己@自己, 忽略
      String alert = replyAuthorName + "在帖子回复中@了你";
      pushUser(
          user.getId(),
          alert,
          topic.getId(),
          replyAuthorName,
          topic.getTitle(),
          PushService.PUSH_TYPE_AT);
    }
  }
 @Aop("redis")
 public CResult add(Topic topic, int userId) {
   if (userId < 1) {
     return _fail("请先登录");
   }
   if (Strings.isBlank(topic.getTitle())
       || topic.getTitle().length() > 1024
       || topic.getTitle().length() < 5) {
     return _fail("标题长度不合法");
   }
   if (Strings.isBlank(topic.getContent())) {
     return _fail("内容不合法");
   }
   if (topic.getTags() != null && topic.getTags().size() > 10) {
     return _fail("最多只能有10个tag");
   }
   if (0 != dao.count(Topic.class, Cnd.where("title", "=", topic.getTitle().trim()))) {
     return _fail("相同标题已经发过了");
   }
   topic.setTitle(Strings.escapeHtml(topic.getTitle().trim()));
   topic.setUserId(userId);
   topic.setTop(false);
   topic.setTags(new HashSet<String>());
   if (topic.getType() == null) topic.setType(TopicType.ask);
   topic.setContent(Toolkit.filteContent(topic.getContent()));
   String oldContent = topic.getContent();
   topic.setContentId(bigContentService.put(topic.getContent()));
   topic.setContent(null);
   dao.insert(topic);
   try {
     topic.setContent(oldContent);
     topicSearchService.add(topic);
   } catch (Exception e) {
   }
   // 如果是ask类型,把帖子加入到 "未回复"列表
   Pipeline pipe = jedis().pipelined();
   if (TopicType.ask.equals(topic.getType())) {
     pipe.zadd(RKEY_TOPIC_NOREPLY, System.currentTimeMillis(), topic.getId());
   }
   pipe.zadd(RKEY_TOPIC_UPDATE + topic.getType(), System.currentTimeMillis(), topic.getId());
   if (topic.getType() != TopicType.shortit)
     pipe.zadd(RKEY_TOPIC_UPDATE_ALL, System.currentTimeMillis(), topic.getId());
   pipe.zincrby(RKEY_USER_SCORE, 100, "" + userId);
   pipe.sync();
   String replyAuthorName = dao.fetch(User.class, userId).getName();
   for (Integer watcherId : globalWatcherIds) {
     if (watcherId != userId)
       pushUser(
           watcherId,
           "新帖:" + topic.getTitle(),
           topic.getId(),
           replyAuthorName,
           topic.getTitle(),
           PushService.PUSH_TYPE_REPLY);
   }
   updateTopicTypeCount();
   return _ok(topic.getId());
 }
 public NutMap _topic(Topic topic, Map<Integer, UserProfile> authors, String mdrender) {
   yvrService.fillTopic(topic, authors);
   NutMap tp = new NutMap();
   tp.put("id", topic.getId());
   tp.put("author_id", "" + topic.getAuthor().getUserId());
   tp.put("tab", topic.getType().toString());
   tp.put(
       "content",
       "false".equals(mdrender)
           ? topic.getContent()
           : Markdowns.toHtml(topic.getContent(), urlbase));
   tp.put("title", StringEscapeUtils.unescapeHtml(topic.getTitle()));
   if (topic.getLastComment() != null)
     tp.put("last_reply_at", _time(topic.getLastComment().getCreateTime()));
   tp.put("good", topic.isGood());
   tp.put("top", topic.isTop());
   tp.put("reply_count", topic.getReplyCount());
   tp.put("visit_count", topic.getVisitCount());
   tp.put("create_at", _time(topic.getCreateTime()));
   UserProfile profile = topic.getAuthor();
   if (profile != null) {
     profile.setScore(yvrService.getUserScore(topic.getUserId()));
   }
   tp.put("author", _author(profile));
   return tp;
 }
  /**
   * @api {get} /yvr/api/v1/topic/:id 获取帖子的详细数据
   * @apiGroup Topic
   * @apiVersion 1.0.0
   * @apiParam {String} id 帖子id
   * @apiParam {boolean} [mdrender=true] 是否渲染Markdown
   * @apiSuccess {Object[]} data 帖子数据
   * @apiSuccess {String} data.id 唯一标示符
   * @apiSuccess {String} data.title 标题
   * @apiSuccess {String} data.tab 类型
   * @apiSuccess {String} data.content 内容
   * @apiSuccess {String} [data.last_reply_at] 最后回复时间
   * @apiSuccess {boolean} data.top 是否置顶
   * @apiSuccess {boolean} data.good 是否为精华帖
   * @apiSuccess {int} data.reply_count 总回复数量
   * @apiSuccess {int} data.visit_count 总浏览数量
   * @apiSuccess {Object} data.author 作者信息
   * @apiSuccess {String} data.author.id 作者id
   * @apiSuccess {String} data.author.loginname 作者登陆名
   * @apiSuccess {Object[]} [data.replies] 回复列表
   * @apiSuccess {String} data.replies.id 回复id
   * @apiSuccess {String} data.replies.author 回复的作者
   * @apiSuccess {String} data.replies.author.id 回复的作者的id
   * @apiSuccess {String} data.replies.author.loginname 回复的作者的登陆名称
   * @apiSuccess {String} data.replies.content 回复的内容
   * @apiSuccess {String[]} data.replies.ups 点赞数
   * @apiSuccess {Object} data.replies.author 回帖作者信息
   * @apiSuccess {String} data.replies.create_at 回帖时间
   * @apiSuccess {String} data.replies.author.id 作者id
   * @apiSuccess {String} data.replies.author.loginname 作者登陆名
   * @apiError 404 The <code>id</code> of the Topic was not found.
   */
  @Aop("redis")
  @GET
  @At("/topic/?")
  public Object topic(String id, @Param("mdrender") String mdrender) {
    Topic topic = dao.fetch(Topic.class, id);
    if (id == null) {
      return HttpStatusView.HTTP_404;
    }
    NutMap tp = _topic(topic, new HashMap<Integer, UserProfile>(), mdrender);

    List<NutMap> replies = new ArrayList<NutMap>();
    for (TopicReply reply :
        dao.query(TopicReply.class, Cnd.where("topicId", "=", id).asc("createTime"))) {
      dao.fetchLinks(reply, null);
      reply.setUps(jedis().zrange(RKEY_REPLY_LIKE + reply.getId(), 0, System.currentTimeMillis()));

      NutMap re = new NutMap();
      re.put("id", reply.getId());
      re.put("author", _author(reply.getAuthor()));

      re.put(
          "content",
          "false".equals(mdrender)
              ? reply.getContent()
              : Markdowns.toHtml(reply.getContent(), urlbase));
      re.put("ups", new ArrayList<String>(reply.getUps()));
      re.put("create_at", _time(reply.getCreateTime()));
      replies.add(re);
    }

    tp.put("replies", replies);

    jedis().zincrby(RKEY_TOPIC_VISIT, 1, topic.getId());
    return _map("data", tp);
  }
 @Aop("redis")
 public void fillTopic(Topic topic, Map<Integer, UserProfile> authors) {
   if (topic.getUserId() == 0) topic.setUserId(1);
   topic.setAuthor(_cacheFetch(authors, topic.getUserId()));
   Double reply_count = jedis().zscore(RKEY_REPLY_COUNT, topic.getId());
   topic.setReplyCount(reply_count == null ? 0 : reply_count.intValue());
   if (topic.getReplyCount() > 0) {
     String replyId = jedis().hget(RKEY_REPLY_LAST, topic.getId());
     TopicReply reply = dao.fetch(TopicReply.class, replyId);
     if (reply != null) {
       if (reply.getUserId() == 0) reply.setUserId(1);
       reply.setAuthor(_cacheFetch(authors, reply.getUserId()));
       topic.setLastComment(reply);
     }
   }
   Double visited = jedis().zscore(RKEY_TOPIC_VISIT, topic.getId());
   topic.setVisitCount((visited == null) ? 0 : visited.intValue());
 }
  /** 全文输出 */
  @At
  @Ok("raw:xml")
  public String rss() throws IOException, FeedException {
    SyndFeed feed = new SyndFeedImpl();
    feed.setFeedType("rss_2.0");
    String urlbase = conf.get("website.urlbase", "https://nutz.cn");
    feed.setLink(urlbase);
    feed.setTitle(conf.get("website.title", "Nutz社区"));
    feed.setDescription(conf.get("website.description", "一个有爱的社区"));

    feed.setAuthor(conf.get("website.author", "wendal"));
    feed.setEncoding("UTF-8");
    feed.setLanguage("zh-cn");

    List<SyndEntry> entries = new ArrayList<SyndEntry>();
    SyndEntry entry;
    SyndContent description;
    List<Topic> list =
        dao.query(Topic.class, Cnd.orderBy().desc("createTime"), dao.createPager(1, 10));
    for (Topic topic : list) {
      dao.fetchLinks(topic, "author");
      entry = new SyndEntryImpl();
      entry.setTitle(topic.getTitle());
      entry.setLink(urlbase + "/yvr/t/" + topic.getId());
      entry.setPublishedDate(topic.getCreateTime());
      description = new SyndContentImpl();
      description.setType("text/html");
      description.setValue(Markdowns.toHtml(topic.getContent(), urlbase));
      entry.setDescription(description);
      entry.setAuthor(topic.getAuthor().getLoginname());
      entries.add(entry);
    }

    feed.setEntries(entries);
    if (list.size() > 0) {
      feed.setPublishedDate(list.get(0).getCreateTime());
    }

    SyndFeedOutput output = new SyndFeedOutput();
    return output.outputString(feed, true);
  }
  protected void _add(Topic topic) {
    if (topic == null) return; // 虽然不太可能,还是预防一下吧
    // 暂时不索引评论
    dao.fetchLinks(topic, "replies");
    Document document;
    document = new Document();
    Field field;
    FieldType fieldType;

    // 先加入id
    fieldType = new FieldType();
    fieldType.setIndexed(true); // 索引
    fieldType.setStored(true); // 存储
    fieldType.setStoreTermVectors(true);
    fieldType.setTokenized(true);
    fieldType.setStoreTermVectorPositions(true); // 存储位置
    fieldType.setStoreTermVectorOffsets(true); // 存储偏移量
    field = new Field("id", topic.getId(), fieldType);
    document.add(field);

    // 加入标题
    fieldType = new FieldType();
    fieldType.setIndexed(true); // 索引
    fieldType.setStored(true); // 存储
    fieldType.setStoreTermVectors(true);
    fieldType.setTokenized(true);
    fieldType.setStoreTermVectorPositions(true); // 存储位置
    fieldType.setStoreTermVectorOffsets(true); // 存储偏移量
    field = new Field("title", topic.getTitle(), fieldType);
    document.add(field);

    // 加入文章内容
    fieldType = new FieldType();
    fieldType.setIndexed(true); // 索引
    fieldType.setStored(false); // 存储
    fieldType.setStoreTermVectors(true);
    fieldType.setTokenized(true);
    fieldType.setStoreTermVectorPositions(true); // 存储位置
    fieldType.setStoreTermVectorOffsets(true); // 存储偏移量
    field = new Field("content", topic.getContent(), fieldType);
    document.add(field);

    StringBuilder sb = new StringBuilder();
    if (topic.getReplies() != null) {
      for (TopicReply reply : topic.getReplies()) {
        if (reply == null) continue;
        bigContentService.fill(reply);
        if (reply.getContent() != null) {
          if (sb.length() + reply.getContent().length() > (IndexWriter.MAX_TERM_LENGTH / 3)) {
            break;
          }
          sb.append(reply.getContent());
        }
      }
    }
    fieldType = new FieldType();
    fieldType.setIndexed(true); // 索引
    fieldType.setStored(false); // 存储
    fieldType.setStoreTermVectors(true);
    fieldType.setTokenized(true);
    fieldType.setStoreTermVectorPositions(true); // 存储位置
    fieldType.setStoreTermVectorOffsets(true); // 存储偏移量

    field = new Field("reply", sb.toString(), fieldType);
    document.add(field);

    try {
      luceneIndex.writer.addDocument(document);
    } catch (IOException e) {
      log.debug("add to index fail : id=" + topic.getId());
    } catch (Error e) {
      log.debug("add to index fail : id=" + topic.getId());
    }
  }